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#[derive(Clone, Debug, Deserialize)]
31struct SimpleError {
32 pub error: Option<String>,
34}
35
36impl std::fmt::Display for SimpleError {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 if let Some(value) = &self.error {
40 write!(f, "{}", value)
41 } else {
42 write!(f, "unknown")
43 }
44 }
45}
46
47#[derive(Clone)]
53pub struct DpopRetry {
54 pub header: Header,
56 pub claims: Claims,
58 pub key_data: KeyData,
60
61 pub check_response_body: bool,
63}
64
65impl DpopRetry {
66 pub fn new(
73 header: Header,
74 claims: Claims,
75 key_data: KeyData,
76 check_response_body: bool,
77 ) -> Self {
78 DpopRetry {
79 header,
80 claims,
81 key_data,
82 check_response_body,
83 }
84 }
85}
86
87#[async_trait::async_trait]
97impl Chainer for DpopRetry {
98 type State = ();
99
100 async fn chain(
112 &self,
113 result: Result<reqwest::Response, reqwest_middleware::Error>,
114 _state: &mut Self::State,
115 request: &mut reqwest::Request,
116 ) -> Result<Option<reqwest::Response>, reqwest_middleware::Error> {
117 let response = result?;
118
119 let status_code = response.status();
120
121 let dpop_status_code = status_code == 400 || status_code == 401;
122 if !dpop_status_code {
123 return Ok(Some(response));
124 };
125
126 let headers = response.headers().clone();
127 tracing::info!(?headers, "headers");
128
129 let www_authenticate_header = headers.get("WWW-Authenticate");
130
131 tracing::info!(?www_authenticate_header, "www_authenticate_header");
132
133 let www_authenticate_value = www_authenticate_header.and_then(|value| value.to_str().ok());
134 let dpop_header_error = www_authenticate_value.is_some_and(is_dpop_error);
135
136 if !dpop_header_error && !self.check_response_body {
137 tracing::info!(
138 ?dpop_header_error,
139 check_response_body = false,
140 "returning response"
141 );
142 return Ok(Some(response));
143 };
144
145 if self.check_response_body {
146 let response_body = match response.json::<serde_json::Value>().await {
147 Err(err) => {
148 return Err(reqwest_middleware::Error::Middleware(
149 DpopError::ResponseBodyParsingFailed(err).into(),
150 ));
151 }
152 Ok(value) => value,
153 };
154 if let Some(response_body_obj) = response_body.as_object() {
155 let error_value = response_body_obj
156 .get("error")
157 .and_then(|value| value.as_str())
158 .unwrap_or("placeholder_unknown_error");
159
160 tracing::info!(?error_value, "response body error");
161
162 if error_value != "invalid_dpop_proof" && error_value != "use_dpop_nonce" {
163 tracing::error!(?error_value, ?response_body_obj, "unexpected error message");
164 return Err(reqwest_middleware::Error::Middleware(
165 DpopError::UnexpectedOAuthError {
166 error: error_value.to_string(),
167 }
168 .into(),
169 ));
170 }
171 } else {
172 return Err(reqwest_middleware::Error::Middleware(
173 DpopError::ResponseBodyObjectParsingFailed.into(),
174 ));
175 }
176 };
177
178 let dpop_header = headers
179 .get("DPoP-Nonce")
180 .and_then(|value| value.to_str().ok());
181
182 tracing::info!(?dpop_header, "dpop nonce header");
183
184 if dpop_header.is_none() {
185 return Err(reqwest_middleware::Error::Middleware(
186 DpopError::MissingDpopNonceHeader.into(),
187 ));
188 }
189 let dpop_header = dpop_header.unwrap();
190
191 let dpop_proof_header = self.header.clone();
192 let mut dpop_proof_claim = self.claims.clone();
193 dpop_proof_claim
194 .private
195 .insert("nonce".to_string(), dpop_header.to_string().into());
196
197 let dpop_proof_token = mint(&self.key_data, &dpop_proof_header, &dpop_proof_claim)
198 .map_err(|err| {
199 reqwest_middleware::Error::Middleware(DpopError::TokenMintingFailed(err).into())
200 })?;
201
202 request.headers_mut().insert(
203 "DPoP",
204 HeaderValue::from_str(&dpop_proof_token).map_err(|err| {
205 reqwest_middleware::Error::Middleware(DpopError::HeaderCreationFailed(err).into())
206 })?,
207 );
208
209 Ok(None)
210 }
211}
212
213pub fn is_dpop_error(value: &str) -> bool {
246 if !value.trim_start().starts_with("DPoP") {
248 return false;
249 }
250
251 let params_part = value.trim_start().strip_prefix("DPoP").unwrap_or("").trim();
253
254 for part in params_part.split(',') {
256 let trimmed = part.trim();
257
258 if let Some(equals_pos) = trimmed.find('=') {
260 let (key, value_part) = trimmed.split_at(equals_pos);
261 let key = key.trim();
262
263 if key == "error" {
264 let value_part = &value_part[1..]; let value_part = value_part.trim();
267
268 let error_value = if let Some(stripped) = value_part.strip_prefix('"') {
270 if value_part.ends_with('"') && value_part.len() >= 2 {
271 &value_part[1..value_part.len() - 1]
272 } else {
273 stripped }
275 } else if let Some(stripped) = value_part.strip_suffix('"') {
276 stripped } else {
278 value_part
279 };
280
281 return error_value == "invalid_dpop_proof" || error_value == "use_dpop_nonce";
282 }
283 }
284 }
285
286 false
287}
288
289pub fn auth_dpop(
308 key_data: &KeyData,
309 http_method: &str,
310 http_uri: &str,
311) -> anyhow::Result<(String, Header, Claims)> {
312 build_dpop(key_data, http_method, http_uri, None)
313}
314
315pub fn request_dpop(
336 key_data: &KeyData,
337 http_method: &str,
338 http_uri: &str,
339 oauth_access_token: &str,
340) -> anyhow::Result<(String, Header, Claims)> {
341 build_dpop(key_data, http_method, http_uri, Some(oauth_access_token))
342}
343
344fn build_dpop(
345 key_data: &KeyData,
346 http_method: &str,
347 http_uri: &str,
348 access_token: Option<&str>,
349) -> anyhow::Result<(String, Header, Claims)> {
350 let now = chrono::Utc::now();
351
352 let public_key_data = to_public(key_data)?;
353 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
354
355 let header = Header {
356 type_: Some("dpop+jwt".to_string()),
357 algorithm: Some("ES256".to_string()),
358 json_web_key: Some(dpop_jwk),
359 ..Default::default()
360 };
361
362 let auth = access_token.map(challenge);
363 let issued_at = Some(now.timestamp() as u64);
364 let expiration = Some((now + chrono::Duration::seconds(30)).timestamp() as u64);
365
366 let claims = Claims::new(JoseClaims {
367 auth,
368 expiration,
369 http_method: Some(http_method.to_string()),
370 http_uri: Some(http_uri.to_string()),
371 issued_at,
372 json_web_token_id: Some(Ulid::new().to_string()),
373 ..Default::default()
374 });
375
376 let token = mint(key_data, &header, &claims)?;
377
378 Ok((token, header, claims))
379}
380
381pub fn extract_jwk_thumbprint(dpop_jwt: &str) -> Result<String> {
412 let parts: Vec<&str> = dpop_jwt.split('.').collect();
414 if parts.len() != 3 {
415 return Err(JWTError::InvalidFormat.into());
416 }
417
418 let encoded_header = parts[0];
419
420 let header_bytes = URL_SAFE_NO_PAD
422 .decode(encoded_header)
423 .map_err(|_| JWTError::InvalidHeader)?;
424
425 let header_json: serde_json::Value =
427 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
428
429 let jwk_value = header_json
431 .get("jwk")
432 .ok_or_else(|| JWTError::MissingClaim {
433 claim: "jwk".to_string(),
434 })?;
435
436 let jwk_object = jwk_value
438 .as_object()
439 .ok_or_else(|| JWKError::MissingField {
440 field: "jwk object".to_string(),
441 })?;
442
443 let mut filtered_jwk = serde_json::Map::new();
445 for field in ["kty", "crv", "x", "y", "d"] {
446 if let Some(value) = jwk_object.get(field) {
447 filtered_jwk.insert(field.to_string(), value.clone());
448 }
449 }
450
451 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
453 .map_err(|e| JWKError::SerializationError {
454 message: e.to_string(),
455 })?;
456
457 let wrapped_jwk = WrappedJsonWebKey {
459 kid: None, alg: None, _use: None, jwk: jwk_ec_key,
463 };
464
465 thumbprint(&wrapped_jwk).map_err(|e| e.into())
467}
468
469#[derive(Debug, Clone)]
473pub struct DpopValidationConfig {
474 pub expected_http_method: Option<String>,
476 pub expected_http_uri: Option<String>,
478 pub expected_access_token_hash: Option<String>,
480 pub max_age_seconds: u64,
482 pub allow_future_iat: bool,
484 pub clock_skew_tolerance_seconds: u64,
486 pub expected_nonce_values: Vec<String>,
488 pub now: i64,
490}
491
492impl Default for DpopValidationConfig {
493 fn default() -> Self {
494 let now = chrono::Utc::now().timestamp();
495 Self {
496 expected_http_method: None,
497 expected_http_uri: None,
498 expected_access_token_hash: None,
499 max_age_seconds: 60,
500 allow_future_iat: false,
501 clock_skew_tolerance_seconds: 30,
502 expected_nonce_values: Vec::new(),
503 now,
504 }
505 }
506}
507
508impl DpopValidationConfig {
509 pub fn for_authorization(http_method: &str, http_uri: &str) -> Self {
511 Self {
512 expected_http_method: Some(http_method.to_string()),
513 expected_http_uri: Some(http_uri.to_string()),
514 expected_access_token_hash: None,
515 ..Default::default()
516 }
517 }
518
519 pub fn for_resource_request(http_method: &str, http_uri: &str, access_token: &str) -> Self {
521 Self {
522 expected_http_method: Some(http_method.to_string()),
523 expected_http_uri: Some(http_uri.to_string()),
524 expected_access_token_hash: Some(challenge(access_token)),
525 ..Default::default()
526 }
527 }
528}
529
530pub fn validate_dpop_jwt(dpop_jwt: &str, config: &DpopValidationConfig) -> Result<String> {
569 let parts: Vec<&str> = dpop_jwt.split('.').collect();
571 if parts.len() != 3 {
572 return Err(JWTError::InvalidFormat.into());
573 }
574
575 let (encoded_header, encoded_payload, encoded_signature) = (parts[0], parts[1], parts[2]);
576
577 let header_bytes = URL_SAFE_NO_PAD
579 .decode(encoded_header)
580 .map_err(|_| JWTError::InvalidHeader)?;
581
582 let header_json: serde_json::Value =
583 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?;
584
585 let typ = header_json
587 .get("typ")
588 .and_then(|v| v.as_str())
589 .ok_or_else(|| JWTError::MissingClaim {
590 claim: "typ".to_string(),
591 })?;
592
593 if typ != "dpop+jwt" {
594 return Err(JWTError::InvalidTokenType {
595 expected: "dpop+jwt".to_string(),
596 actual: typ.to_string(),
597 }
598 .into());
599 }
600
601 let alg = header_json
603 .get("alg")
604 .and_then(|v| v.as_str())
605 .ok_or_else(|| JWTError::MissingClaim {
606 claim: "alg".to_string(),
607 })?;
608
609 if !matches!(alg, "ES256" | "ES384" | "ES256K") {
610 return Err(JWTError::UnsupportedAlgorithm {
611 algorithm: alg.to_string(),
612 key_type: "EC".to_string(),
613 }
614 .into());
615 }
616
617 let jwk_value = header_json
619 .get("jwk")
620 .ok_or_else(|| JWTError::MissingClaim {
621 claim: "jwk".to_string(),
622 })?;
623
624 let jwk_object = jwk_value
625 .as_object()
626 .ok_or_else(|| JWKError::MissingField {
627 field: "jwk object".to_string(),
628 })?;
629
630 let mut filtered_jwk = serde_json::Map::new();
632 for field in ["kty", "crv", "x", "y", "d"] {
633 if let Some(value) = jwk_object.get(field) {
634 filtered_jwk.insert(field.to_string(), value.clone());
635 }
636 }
637
638 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk))
639 .map_err(|e| JWKError::SerializationError {
640 message: e.to_string(),
641 })?;
642
643 let wrapped_jwk = WrappedJsonWebKey {
645 kid: None,
646 alg: Some(alg.to_string()),
647 _use: Some("sig".to_string()),
648 jwk: jwk_ec_key,
649 };
650
651 let key_data = to_key_data(&wrapped_jwk)?;
653
654 let payload_bytes = URL_SAFE_NO_PAD
656 .decode(encoded_payload)
657 .map_err(|_| JWTError::InvalidPayload)?;
658
659 let claims: serde_json::Value =
660 serde_json::from_slice(&payload_bytes).map_err(|_| JWTError::InvalidPayloadJson)?;
661
662 claims
665 .get("jti")
666 .and_then(|v| v.as_str())
667 .ok_or_else(|| JWTError::MissingClaim {
668 claim: "jti".to_string(),
669 })?;
670
671 if let Some(expected_method) = &config.expected_http_method {
672 let htm =
674 claims
675 .get("htm")
676 .and_then(|v| v.as_str())
677 .ok_or_else(|| JWTError::MissingClaim {
678 claim: "htm".to_string(),
679 })?;
680
681 if htm != expected_method {
682 return Err(JWTError::HttpMethodMismatch {
683 expected: expected_method.clone(),
684 actual: htm.to_string(),
685 }
686 .into());
687 }
688 }
689
690 if let Some(expected_uri) = &config.expected_http_uri {
691 let htu =
693 claims
694 .get("htu")
695 .and_then(|v| v.as_str())
696 .ok_or_else(|| JWTError::MissingClaim {
697 claim: "htu".to_string(),
698 })?;
699
700 if htu != expected_uri {
701 return Err(JWTError::HttpUriMismatch {
702 expected: expected_uri.clone(),
703 actual: htu.to_string(),
704 }
705 .into());
706 }
707 }
708
709 let iat = claims
711 .get("iat")
712 .and_then(|v| v.as_u64())
713 .ok_or_else(|| JWTError::MissingClaim {
714 claim: "iat".to_string(),
715 })?;
716
717 if config.now as u64 > iat + config.max_age_seconds + config.clock_skew_tolerance_seconds {
719 return Err(JWTError::InvalidTimestamp {
720 reason: format!(
721 "Token too old: issued at {} but max age is {} seconds",
722 iat, config.max_age_seconds
723 ),
724 }
725 .into());
726 }
727
728 if !config.allow_future_iat && iat > config.now as u64 + config.clock_skew_tolerance_seconds {
730 return Err(JWTError::InvalidTimestamp {
731 reason: format!(
732 "Token from future: issued at {} but current time is {}",
733 iat, config.now
734 ),
735 }
736 .into());
737 }
738
739 if let Some(expected_ath) = &config.expected_access_token_hash {
741 let ath =
742 claims
743 .get("ath")
744 .and_then(|v| v.as_str())
745 .ok_or_else(|| JWTError::MissingClaim {
746 claim: "ath".to_string(),
747 })?;
748
749 if ath != expected_ath {
750 return Err(JWTError::AccessTokenHashMismatch.into());
751 }
752 }
753
754 if !config.expected_nonce_values.is_empty() {
756 let nonce = claims
757 .get("nonce")
758 .and_then(|v| v.as_str())
759 .ok_or_else(|| JWTError::MissingClaim {
760 claim: "nonce".to_string(),
761 })?;
762
763 if !config.expected_nonce_values.contains(&nonce.to_string()) {
764 return Err(JWTError::InvalidNonce {
765 nonce: nonce.to_string(),
766 }
767 .into());
768 }
769 }
770
771 if let Some(exp_value) = claims.get("exp") {
773 if let Some(exp) = exp_value.as_u64() {
774 if config.now as u64 >= exp {
775 return Err(JWTError::TokenExpired.into());
776 }
777 }
778 }
779
780 let content = format!("{}.{}", encoded_header, encoded_payload);
782 let signature_bytes = URL_SAFE_NO_PAD
783 .decode(encoded_signature)
784 .map_err(|_| JWTError::InvalidSignature)?;
785
786 validate(&key_data, &signature_bytes, content.as_bytes())
787 .map_err(|_| JWTError::SignatureVerificationFailed)?;
788
789 thumbprint(&wrapped_jwk).map_err(|e| e.into())
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[test]
798 fn test_is_dpop_error_invalid_dpop_proof() {
799 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="invalid_dpop_proof", error_description="DPoP proof required""#;
800 assert!(is_dpop_error(header));
801 }
802
803 #[test]
804 fn test_is_dpop_error_use_dpop_nonce() {
805 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""#;
806 assert!(is_dpop_error(header));
807 }
808
809 #[test]
810 fn test_is_dpop_error_other_error() {
811 let header =
812 r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#;
813 assert!(!is_dpop_error(header));
814 }
815
816 #[test]
817 fn test_is_dpop_error_no_error_field() {
818 let header = r#"DPoP algs="ES256", error_description="Some description""#;
819 assert!(!is_dpop_error(header));
820 }
821
822 #[test]
823 fn test_is_dpop_error_not_dpop_header() {
824 let header = r#"Bearer error="invalid_token""#;
825 assert!(!is_dpop_error(header));
826 }
827
828 #[test]
829 fn test_is_dpop_error_empty_string() {
830 assert!(!is_dpop_error(""));
831 }
832
833 #[test]
834 fn test_is_dpop_error_minimal_valid() {
835 let header = r#"DPoP error="invalid_dpop_proof""#;
836 assert!(is_dpop_error(header));
837 }
838
839 #[test]
840 fn test_is_dpop_error_unquoted_value() {
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_whitespace_handling() {
847 let header =
848 r#" DPoP algs="ES256" , error="use_dpop_nonce" , error_description="test" "#;
849 assert!(is_dpop_error(header));
850 }
851
852 #[test]
853 fn test_is_dpop_error_case_sensitive_scheme() {
854 let header = r#"dpop error="invalid_dpop_proof""#;
855 assert!(!is_dpop_error(header));
856 }
857
858 #[test]
859 fn test_is_dpop_error_case_sensitive_error_value() {
860 let header = r#"DPoP error="INVALID_DPOP_PROOF""#;
861 assert!(!is_dpop_error(header));
862 }
863
864 #[test]
865 fn test_is_dpop_error_malformed_quotes() {
866 let header = r#"DPoP error="invalid_dpop_proof"#;
867 assert!(is_dpop_error(header));
868 }
869
870 #[test]
871 fn test_is_dpop_error_multiple_error_fields() {
872 let header = r#"DPoP error="invalid_token", algs="ES256", error="invalid_dpop_proof""#;
873 assert!(!is_dpop_error(header));
875 }
876
877 #[test]
878 fn test_extract_jwk_thumbprint_with_known_jwt() -> anyhow::Result<()> {
879 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
881
882 let thumbprint = extract_jwk_thumbprint(dpop_jwt)?;
883
884 assert_eq!(thumbprint.len(), 43); assert!(!thumbprint.contains('=')); assert!(!thumbprint.contains('+')); assert!(!thumbprint.contains('/')); let thumbprint2 = extract_jwk_thumbprint(dpop_jwt)?;
892 assert_eq!(thumbprint, thumbprint2);
893
894 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
895
896 Ok(())
897 }
898
899 #[test]
900 fn test_extract_jwk_thumbprint_invalid_jwt_format() {
901 let invalid_jwt = "invalid.jwt";
903 let result = extract_jwk_thumbprint(invalid_jwt);
904 assert!(result.is_err());
905 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
906 }
907
908 #[test]
909 fn test_extract_jwk_thumbprint_invalid_header() {
910 let invalid_jwt = "invalid-base64.eyJqdGkiOiJ0ZXN0In0.signature";
912 let result = extract_jwk_thumbprint(invalid_jwt);
913 assert!(result.is_err());
914 assert!(
915 result
916 .unwrap_err()
917 .to_string()
918 .contains("Invalid JWT header")
919 );
920 }
921
922 #[test]
923 fn test_extract_jwk_thumbprint_missing_jwk() -> anyhow::Result<()> {
924 let header = serde_json::json!({
926 "alg": "ES256",
927 "typ": "dpop+jwt"
928 });
929 let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD
930 .encode(serde_json::to_string(&header)?.as_bytes());
931
932 let jwt_without_jwk = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
933 let result = extract_jwk_thumbprint(&jwt_without_jwk);
934 assert!(result.is_err());
935 assert!(
936 result
937 .unwrap_err()
938 .to_string()
939 .contains("Missing required claim: jwk")
940 );
941
942 Ok(())
943 }
944
945 #[test]
946 fn test_extract_jwk_thumbprint_with_generated_dpop() -> anyhow::Result<()> {
947 use atproto_identity::key::{KeyType, generate_key};
949
950 let key_data = generate_key(KeyType::P256Private)?;
951 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
952
953 let thumbprint = extract_jwk_thumbprint(&dpop_token)?;
954
955 assert_eq!(thumbprint.len(), 43);
957 assert!(!thumbprint.contains('='));
958 assert!(!thumbprint.contains('+'));
959 assert!(!thumbprint.contains('/'));
960
961 let thumbprint2 = extract_jwk_thumbprint(&dpop_token)?;
963 assert_eq!(thumbprint, thumbprint2);
964
965 Ok(())
966 }
967
968 #[test]
969 fn test_extract_jwk_thumbprint_different_keys_different_thumbprints() -> anyhow::Result<()> {
970 use atproto_identity::key::{KeyType, generate_key};
972
973 let key1 = generate_key(KeyType::P256Private)?;
974 let key2 = generate_key(KeyType::P256Private)?;
975
976 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
977 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
978
979 let thumbprint1 = extract_jwk_thumbprint(&dpop_token1)?;
980 let thumbprint2 = extract_jwk_thumbprint(&dpop_token2)?;
981
982 assert_ne!(thumbprint1, thumbprint2);
983
984 Ok(())
985 }
986
987 #[test]
990 fn test_validate_dpop_jwt_with_known_jwt() -> anyhow::Result<()> {
991 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
994
995 let config = DpopValidationConfig {
997 expected_http_method: Some("POST".to_string()),
998 expected_http_uri: Some("https://aipdev.tunn.dev/oauth/token".to_string()),
999 expected_access_token_hash: None,
1000 max_age_seconds: 365 * 24 * 60 * 60, allow_future_iat: true,
1002 clock_skew_tolerance_seconds: 365 * 24 * 60 * 60, expected_nonce_values: Vec::new(),
1004 now: 1,
1005 };
1006
1007 let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?;
1008
1009 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU");
1011 assert_eq!(thumbprint.len(), 43);
1012
1013 Ok(())
1014 }
1015
1016 #[test]
1017 fn test_validate_dpop_jwt_with_generated_jwt() -> anyhow::Result<()> {
1018 use atproto_identity::key::{KeyType, generate_key};
1020
1021 let key_data = generate_key(KeyType::P256Private)?;
1022 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1023
1024 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1025 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1026
1027 assert_eq!(thumbprint.len(), 43);
1029 assert!(!thumbprint.contains('='));
1030 assert!(!thumbprint.contains('+'));
1031 assert!(!thumbprint.contains('/'));
1032
1033 Ok(())
1034 }
1035
1036 #[test]
1037 fn test_validate_dpop_jwt_invalid_format() {
1038 let config = DpopValidationConfig::default();
1039
1040 let result = validate_dpop_jwt("invalid.jwt", &config);
1042 assert!(result.is_err());
1043 assert!(result.unwrap_err().to_string().contains("expected 3 parts"));
1044 }
1045
1046 #[test]
1047 fn test_validate_dpop_jwt_invalid_typ() -> anyhow::Result<()> {
1048 let header = serde_json::json!({
1050 "alg": "ES256",
1051 "typ": "JWT", "jwk": {
1053 "kty": "EC",
1054 "crv": "P-256",
1055 "x": "test",
1056 "y": "test"
1057 }
1058 });
1059 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1060 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1061
1062 let config = DpopValidationConfig::default();
1063 let result = validate_dpop_jwt(&jwt, &config);
1064 assert!(result.is_err());
1065 assert!(
1066 result
1067 .unwrap_err()
1068 .to_string()
1069 .contains("Invalid token type")
1070 );
1071
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn test_validate_dpop_jwt_unsupported_algorithm() -> anyhow::Result<()> {
1077 let header = serde_json::json!({
1079 "alg": "HS256", "typ": "dpop+jwt",
1081 "jwk": {
1082 "kty": "EC",
1083 "crv": "P-256",
1084 "x": "test",
1085 "y": "test"
1086 }
1087 });
1088 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1089 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1090
1091 let config = DpopValidationConfig::default();
1092 let result = validate_dpop_jwt(&jwt, &config);
1093 assert!(result.is_err());
1094 assert!(
1095 result
1096 .unwrap_err()
1097 .to_string()
1098 .contains("Unsupported JWT algorithm")
1099 );
1100
1101 Ok(())
1102 }
1103
1104 #[test]
1105 fn test_validate_dpop_jwt_missing_jwk() -> anyhow::Result<()> {
1106 let header = serde_json::json!({
1108 "alg": "ES256",
1109 "typ": "dpop+jwt"
1110 });
1112 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
1113 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header);
1114
1115 let config = DpopValidationConfig::default();
1116 let result = validate_dpop_jwt(&jwt, &config);
1117 assert!(result.is_err());
1118 assert!(
1119 result
1120 .unwrap_err()
1121 .to_string()
1122 .contains("Missing required claim: jwk")
1123 );
1124
1125 Ok(())
1126 }
1127
1128 #[test]
1129 fn test_validate_dpop_jwt_missing_claims() -> anyhow::Result<()> {
1130 use atproto_identity::key::{KeyType, generate_key};
1131
1132 let key_data = generate_key(KeyType::P256Private)?;
1133 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1134
1135 let parts: Vec<&str> = dpop_token.split('.').collect();
1137 let payload = serde_json::json!({
1138 "htm": "POST",
1140 "htu": "https://example.com/token",
1141 "iat": chrono::Utc::now().timestamp()
1142 });
1143 let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1144 let modified_jwt = format!("{}.{}.{}", parts[0], encoded_payload, parts[2]);
1145
1146 let config = DpopValidationConfig::default();
1147 let result = validate_dpop_jwt(&modified_jwt, &config);
1148 assert!(result.is_err());
1149 assert!(
1150 result
1151 .unwrap_err()
1152 .to_string()
1153 .contains("Missing required claim: jti")
1154 );
1155
1156 Ok(())
1157 }
1158
1159 #[test]
1160 fn test_validate_dpop_jwt_http_method_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("GET", "https://example.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 method mismatch")
1175 );
1176
1177 Ok(())
1178 }
1179
1180 #[test]
1181 fn test_validate_dpop_jwt_http_uri_mismatch() -> 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_authorization("POST", "https://different.com/token");
1189 let result = validate_dpop_jwt(&dpop_token, &config);
1190 assert!(result.is_err());
1191 assert!(
1192 result
1193 .unwrap_err()
1194 .to_string()
1195 .contains("HTTP URI mismatch")
1196 );
1197
1198 Ok(())
1199 }
1200
1201 #[test]
1202 fn test_validate_dpop_jwt_require_access_token_hash() -> anyhow::Result<()> {
1203 use atproto_identity::key::{KeyType, generate_key};
1204
1205 let key_data = generate_key(KeyType::P256Private)?;
1206 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1207
1208 let config = DpopValidationConfig::for_resource_request(
1210 "POST",
1211 "https://example.com/token",
1212 "access_token",
1213 );
1214 let result = validate_dpop_jwt(&dpop_token, &config);
1215 assert!(result.is_err());
1216 assert!(
1217 result
1218 .unwrap_err()
1219 .to_string()
1220 .contains("Missing required claim: ath")
1221 );
1222
1223 Ok(())
1224 }
1225
1226 #[test]
1227 fn test_validate_dpop_jwt_with_resource_request() -> anyhow::Result<()> {
1228 use atproto_identity::key::{KeyType, generate_key};
1229
1230 let key_data = generate_key(KeyType::P256Private)?;
1231 let access_token = "test_access_token";
1232 let (dpop_token, _, _) = request_dpop(
1233 &key_data,
1234 "GET",
1235 "https://example.com/resource",
1236 access_token,
1237 )?;
1238
1239 let config = DpopValidationConfig::for_resource_request(
1241 "GET",
1242 "https://example.com/resource",
1243 access_token,
1244 );
1245 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1246
1247 assert_eq!(thumbprint.len(), 43);
1248
1249 Ok(())
1250 }
1251
1252 #[test]
1253 fn test_validate_dpop_jwt_timestamp_validation() -> anyhow::Result<()> {
1254 use atproto_identity::key::{KeyType, generate_key};
1255
1256 let key_data = generate_key(KeyType::P256Private)?;
1257
1258 let old_time = chrono::Utc::now().timestamp() as u64 - 3600; let public_key_data = to_public(&key_data)?;
1262 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?;
1263
1264 let header = Header {
1265 type_: Some("dpop+jwt".to_string()),
1266 algorithm: Some("ES256".to_string()),
1267 json_web_key: Some(dpop_jwk),
1268 ..Default::default()
1269 };
1270
1271 let claims = Claims::new(JoseClaims {
1272 json_web_token_id: Some(Ulid::new().to_string()),
1273 http_method: Some("POST".to_string()),
1274 http_uri: Some("https://example.com/token".to_string()),
1275 issued_at: Some(old_time),
1276 ..Default::default()
1277 });
1278
1279 let old_token = mint(&key_data, &header, &claims)?;
1280
1281 let config = DpopValidationConfig {
1283 expected_http_method: Some("POST".to_string()),
1284 expected_http_uri: Some("https://example.com/token".to_string()),
1285 max_age_seconds: 60, ..Default::default()
1287 };
1288
1289 let result = validate_dpop_jwt(&old_token, &config);
1290 assert!(result.is_err());
1291 assert!(result.unwrap_err().to_string().contains("Token too old"));
1292
1293 Ok(())
1294 }
1295
1296 #[test]
1297 fn test_validate_dpop_jwt_different_keys_different_thumbprints() -> anyhow::Result<()> {
1298 use atproto_identity::key::{KeyType, generate_key};
1299
1300 let key1 = generate_key(KeyType::P256Private)?;
1301 let key2 = generate_key(KeyType::P256Private)?;
1302
1303 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?;
1304 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?;
1305
1306 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1307
1308 let thumbprint1 = validate_dpop_jwt(&dpop_token1, &config)?;
1309 let thumbprint2 = validate_dpop_jwt(&dpop_token2, &config)?;
1310
1311 assert_ne!(thumbprint1, thumbprint2);
1312
1313 Ok(())
1314 }
1315
1316 #[test]
1317 fn test_validate_dpop_jwt_config_for_authorization() {
1318 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/auth");
1319
1320 assert_eq!(config.expected_http_method, Some("POST".to_string()));
1321 assert_eq!(
1322 config.expected_http_uri,
1323 Some("https://example.com/auth".to_string())
1324 );
1325 assert_eq!(config.max_age_seconds, 60);
1326 assert!(!config.allow_future_iat);
1327 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1328 }
1329
1330 #[test]
1331 fn test_validate_dpop_jwt_config_for_resource_request() {
1332 let config = DpopValidationConfig::for_resource_request(
1333 "GET",
1334 "https://example.com/resource",
1335 "access_token",
1336 );
1337
1338 assert_eq!(config.expected_http_method, Some("GET".to_string()));
1339 assert_eq!(
1340 config.expected_http_uri,
1341 Some("https://example.com/resource".to_string())
1342 );
1343 assert_eq!(config.max_age_seconds, 60);
1344 assert!(!config.allow_future_iat);
1345 assert_eq!(config.clock_skew_tolerance_seconds, 30);
1346 }
1347
1348 #[test]
1349 fn test_validate_dpop_jwt_permissive_config() -> anyhow::Result<()> {
1350 use atproto_identity::key::{KeyType, generate_key};
1351
1352 let key_data = generate_key(KeyType::P256Private)?;
1353 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1354
1355 let now = chrono::Utc::now().timestamp();
1356
1357 let config = DpopValidationConfig {
1359 expected_http_method: None,
1360 expected_http_uri: None,
1361 expected_access_token_hash: None,
1362 max_age_seconds: 3600,
1363 allow_future_iat: true,
1364 clock_skew_tolerance_seconds: 300,
1365 expected_nonce_values: Vec::new(),
1366 now,
1367 };
1368
1369 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1370 assert_eq!(thumbprint.len(), 43);
1371
1372 Ok(())
1373 }
1374
1375 #[test]
1376 fn test_validate_dpop_jwt_nonce_validation_success() -> anyhow::Result<()> {
1377 use atproto_identity::key::{KeyType, generate_key};
1378
1379 let key_data = generate_key(KeyType::P256Private)?;
1380
1381 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1383
1384 let test_nonce = "test_nonce_12345";
1386 claims.private.insert("nonce".to_string(), test_nonce.into());
1387
1388 let dpop_token = mint(&key_data, &header, &claims)?;
1390
1391 let mut config =
1393 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1394 config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()];
1395
1396 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1397 assert_eq!(thumbprint.len(), 43);
1398
1399 Ok(())
1400 }
1401
1402 #[test]
1403 fn test_validate_dpop_jwt_nonce_validation_failure() -> anyhow::Result<()> {
1404 use atproto_identity::key::{KeyType, generate_key};
1405
1406 let key_data = generate_key(KeyType::P256Private)?;
1407
1408 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1410
1411 let token_nonce = "token_nonce_that_wont_match";
1413 claims.private.insert("nonce".to_string(), token_nonce.into());
1414
1415 let dpop_token = mint(&key_data, &header, &claims)?;
1417
1418 let mut config =
1420 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1421 config.expected_nonce_values = vec![
1422 "expected_nonce_1".to_string(),
1423 "expected_nonce_2".to_string(),
1424 ];
1425
1426 let result = validate_dpop_jwt(&dpop_token, &config);
1427 assert!(result.is_err());
1428 let error_msg = result.unwrap_err().to_string();
1429 assert!(error_msg.contains("Invalid nonce"));
1430
1431 Ok(())
1432 }
1433
1434 #[test]
1435 fn test_validate_dpop_jwt_nonce_missing_when_required() -> anyhow::Result<()> {
1436 use atproto_identity::key::{KeyType, generate_key};
1437
1438 let key_data = generate_key(KeyType::P256Private)?;
1439 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1440
1441 let parts: Vec<&str> = dpop_token.split('.').collect();
1443 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?;
1444 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?;
1445
1446 payload.as_object_mut().unwrap().remove("nonce");
1448
1449 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
1450 let modified_jwt = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
1451
1452 let mut config =
1454 DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1455 config.expected_nonce_values = vec!["required_nonce".to_string()];
1456
1457 let result = validate_dpop_jwt(&modified_jwt, &config);
1458 assert!(result.is_err());
1459 assert!(
1460 result
1461 .unwrap_err()
1462 .to_string()
1463 .contains("Missing required claim: nonce")
1464 );
1465
1466 Ok(())
1467 }
1468
1469 #[test]
1470 fn test_validate_dpop_jwt_nonce_empty_array_skips_validation() -> anyhow::Result<()> {
1471 use atproto_identity::key::{KeyType, generate_key};
1472
1473 let key_data = generate_key(KeyType::P256Private)?;
1474 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?;
1475
1476 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token");
1478 assert!(config.expected_nonce_values.is_empty());
1479
1480 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?;
1481 assert_eq!(thumbprint.len(), 43);
1482
1483 Ok(())
1484 }
1485}