A library for ATProtocol identities.
1//! DPoP (Demonstration of Proof-of-Possession) implementation. 2//! 3//! RFC 9449 compliant DPoP token generation with automatic retry middleware 4//! for nonce challenges and ES256 signature support. 5 6use crate::errors::{JWKError, JWTError}; 7use anyhow::Result; 8use atproto_identity::key::{KeyData, to_public, validate}; 9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 10use elliptic_curve::JwkEcKey; 11use reqwest::header::HeaderValue; 12use reqwest_chain::Chainer; 13use ulid::Ulid; 14 15use crate::{ 16 errors::DpopError, 17 jwk::{WrappedJsonWebKey, thumbprint, to_key_data}, 18 jwt::{Claims, Header, JoseClaims, mint}, 19 pkce::challenge, 20}; 21 22/// Retry middleware for handling DPoP nonce challenges in HTTP requests. 23/// 24/// This struct implements the `Chainer` trait to automatically retry requests 25/// when the server responds with a "use_dpop_nonce" error, adding the required 26/// nonce to the DPoP proof before retrying. 27#[derive(Clone)] 28pub struct DpopRetry { 29 /// The JWT header for the DPoP proof. 30 pub header: Header, 31 /// The JWT claims for the DPoP proof. 32 pub claims: Claims, 33 /// The cryptographic key data used to sign the DPoP proof. 34 pub key_data: KeyData, 35 36 /// Whether to check the response body for DPoP errors in addition to headers. 37 pub check_response_body: bool, 38} 39 40impl DpopRetry { 41 /// Creates a new DpopRetry instance with the provided header, claims, and key data. 42 /// 43 /// # Arguments 44 /// * `header` - The JWT header for the DPoP proof 45 /// * `claims` - The JWT claims for the DPoP proof 46 /// * `key_data` - The cryptographic key data for signing 47 pub fn new( 48 header: Header, 49 claims: Claims, 50 key_data: KeyData, 51 check_response_body: bool, 52 ) -> Self { 53 DpopRetry { 54 header, 55 claims, 56 key_data, 57 check_response_body, 58 } 59 } 60} 61 62/// Implementation of the `Chainer` trait for handling DPoP nonce challenges. 63/// 64/// This middleware intercepts HTTP responses with 400/401 status codes and 65/// "use_dpop_nonce" errors, extracts the DPoP-Nonce header, and retries 66/// the request with an updated DPoP proof containing the nonce. 67/// 68/// This does not evaluate the response body to determine if a DPoP error was 69/// returned. Only the returned "WWW-Authenticate" header is evaluated. This 70/// is the expected and defined behavior per RFC7235 sections 3.1 and 4.1. 71#[async_trait::async_trait] 72impl Chainer for DpopRetry { 73 type State = (); 74 75 /// Handles the retry logic for DPoP nonce challenges. 76 /// 77 /// # Arguments 78 /// * `result` - The result of the HTTP request 79 /// * `_state` - Unused state (unit type) 80 /// * `request` - The mutable request to potentially retry 81 /// 82 /// # Returns 83 /// * `Ok(Some(response))` - Original response if no retry needed 84 /// * `Ok(None)` - Retry the request with updated DPoP proof 85 /// * `Err(error)` - Error if retry logic fails 86 async fn chain( 87 &self, 88 result: Result<reqwest::Response, reqwest_middleware::Error>, 89 _state: &mut Self::State, 90 request: &mut reqwest::Request, 91 ) -> Result<Option<reqwest::Response>, reqwest_middleware::Error> { 92 let response = result?; 93 94 let status_code = response.status(); 95 96 let dpop_status_code = status_code == 400 || status_code == 401; 97 if !dpop_status_code { 98 return Ok(Some(response)); 99 }; 100 101 let headers = response.headers().clone(); 102 let www_authenticate_header = headers.get("WWW-Authenticate"); 103 let www_authenticate_value = www_authenticate_header.and_then(|value| value.to_str().ok()); 104 let dpop_header_error = www_authenticate_value.is_some_and(is_dpop_error); 105 106 if !dpop_header_error && !self.check_response_body { 107 return Ok(Some(response)); 108 }; 109 110 if self.check_response_body { 111 let response_body = match response.json::<serde_json::Value>().await { 112 Err(err) => { 113 return Err(reqwest_middleware::Error::Middleware( 114 DpopError::ResponseBodyParsingFailed(err).into(), 115 )); 116 } 117 Ok(value) => value, 118 }; 119 if let Some(response_body_obj) = response_body.as_object() { 120 let error_value = response_body_obj 121 .get("error") 122 .and_then(|value| value.as_str()) 123 .unwrap_or("placeholder_unknown_error"); 124 125 if error_value != "invalid_dpop_proof" && error_value != "use_dpop_nonce" { 126 return Err(reqwest_middleware::Error::Middleware( 127 DpopError::UnexpectedOAuthError { 128 error: error_value.to_string(), 129 } 130 .into(), 131 )); 132 } 133 } else { 134 return Err(reqwest_middleware::Error::Middleware( 135 DpopError::ResponseBodyObjectParsingFailed.into(), 136 )); 137 } 138 }; 139 140 let dpop_header = headers 141 .get("DPoP-Nonce") 142 .and_then(|value| value.to_str().ok()); 143 144 if dpop_header.is_none() { 145 return Err(reqwest_middleware::Error::Middleware( 146 DpopError::MissingDpopNonceHeader.into(), 147 )); 148 } 149 let dpop_header = dpop_header.unwrap(); 150 151 let dpop_proof_header = self.header.clone(); 152 let mut dpop_proof_claim = self.claims.clone(); 153 dpop_proof_claim 154 .private 155 .insert("nonce".to_string(), dpop_header.to_string().into()); 156 157 let dpop_proof_token = mint(&self.key_data, &dpop_proof_header, &dpop_proof_claim) 158 .map_err(|err| { 159 reqwest_middleware::Error::Middleware(DpopError::TokenMintingFailed(err).into()) 160 })?; 161 162 request.headers_mut().insert( 163 "DPoP", 164 HeaderValue::from_str(&dpop_proof_token).map_err(|err| { 165 reqwest_middleware::Error::Middleware(DpopError::HeaderCreationFailed(err).into()) 166 })?, 167 ); 168 169 Ok(None) 170 } 171} 172 173/// Parses the value of the "WWW-Authenticate" header and returns true if the inner "error" field is either "invalid_dpop_proof" or "use_dpop_nonce". 174/// 175/// This function parses DPoP challenge headers to determine if the server is requesting 176/// a DPoP proof or indicating that the provided proof is invalid. 177/// 178/// # Arguments 179/// * `value` - The WWW-Authenticate header value to parse 180/// 181/// # Returns 182/// * `true` if the error field indicates a DPoP-related error 183/// * `false` if no DPoP error is found or the header format is invalid 184/// 185/// # Examples 186/// ```no_run 187/// use atproto_oauth::dpop::is_dpop_error; 188/// 189/// // Valid DPoP error: invalid_dpop_proof 190/// let header1 = r#"DPoP algs="ES256", error="invalid_dpop_proof", error_description="DPoP proof required""#; 191/// assert!(is_dpop_error(header1)); 192/// 193/// // Valid DPoP error: use_dpop_nonce 194/// let header2 = r#"DPoP algs="ES256", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#; 195/// assert!(is_dpop_error(header2)); 196/// 197/// // Non-DPoP error 198/// let header3 = r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#; 199/// assert!(!is_dpop_error(header3)); 200/// 201/// // Non-DPoP authentication scheme 202/// let header4 = r#"Bearer error="invalid_token""#; 203/// assert!(!is_dpop_error(header4)); 204/// ``` 205pub fn is_dpop_error(value: &str) -> bool { 206 // Check if the header starts with "DPoP" 207 if !value.trim_start().starts_with("DPoP") { 208 return false; 209 } 210 211 // Remove the "DPoP" scheme prefix and parse the parameters 212 let params_part = value.trim_start().strip_prefix("DPoP").unwrap_or("").trim(); 213 214 // Split by commas and look for error field 215 for part in params_part.split(',') { 216 let trimmed = part.trim(); 217 218 // Look for error="value" pattern 219 if let Some(equals_pos) = trimmed.find('=') { 220 let (key, value_part) = trimmed.split_at(equals_pos); 221 let key = key.trim(); 222 223 if key == "error" { 224 // Extract the quoted value 225 let value_part = &value_part[1..]; // Skip the '=' 226 let value_part = value_part.trim(); 227 228 // Remove surrounding quotes if present (handle malformed quotes too) 229 let error_value = if let Some(stripped) = value_part.strip_prefix('"') { 230 if value_part.ends_with('"') && value_part.len() >= 2 { 231 &value_part[1..value_part.len() - 1] 232 } else { 233 stripped // Remove leading quote even if no closing quote 234 } 235 } else if let Some(stripped) = value_part.strip_suffix('"') { 236 stripped // Remove trailing quote if no leading quote 237 } else { 238 value_part 239 }; 240 241 return error_value == "invalid_dpop_proof" || error_value == "use_dpop_nonce"; 242 } 243 } 244 } 245 246 false 247} 248 249/// Creates a DPoP proof token for OAuth authorization requests. 250/// 251/// Generates a JWT with the required DPoP claims for proving possession 252/// of the private key during OAuth authorization flows. 253/// 254/// # Arguments 255/// * `key_data` - The cryptographic key data for signing the proof 256/// * `http_method` - The HTTP method of the request (e.g., "POST") 257/// * `http_uri` - The full URI of the authorization endpoint 258/// 259/// # Returns 260/// A tuple containing: 261/// * The signed JWT token as a string 262/// * The JWT header used for signing 263/// * The JWT claims used in the token 264/// 265/// # Errors 266/// Returns an error if key conversion or token minting fails. 267pub fn auth_dpop( 268 key_data: &KeyData, 269 http_method: &str, 270 http_uri: &str, 271) -> anyhow::Result<(String, Header, Claims)> { 272 build_dpop(key_data, http_method, http_uri, None) 273} 274 275/// Creates a DPoP proof token for OAuth resource requests. 276/// 277/// Generates a JWT with the required DPoP claims for proving possession 278/// of the private key when making requests to protected resources using 279/// an OAuth access token. 280/// 281/// # Arguments 282/// * `key_data` - The cryptographic key data for signing the proof 283/// * `http_method` - The HTTP method of the request (e.g., "GET") 284/// * `http_uri` - The full URI of the resource endpoint 285/// * `oauth_access_token` - The OAuth access token being used 286/// 287/// # Returns 288/// A tuple containing: 289/// * The signed JWT token as a string 290/// * The JWT header used for signing 291/// * The JWT claims used in the token 292/// 293/// # Errors 294/// Returns an error if key conversion or token minting fails. 295pub fn request_dpop( 296 key_data: &KeyData, 297 http_method: &str, 298 http_uri: &str, 299 oauth_access_token: &str, 300) -> anyhow::Result<(String, Header, Claims)> { 301 build_dpop(key_data, http_method, http_uri, Some(oauth_access_token)) 302} 303 304fn build_dpop( 305 key_data: &KeyData, 306 http_method: &str, 307 http_uri: &str, 308 access_token: Option<&str>, 309) -> anyhow::Result<(String, Header, Claims)> { 310 let now = chrono::Utc::now(); 311 312 let public_key_data = to_public(key_data)?; 313 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?; 314 315 let header = Header { 316 type_: Some("dpop+jwt".to_string()), 317 algorithm: Some("ES256".to_string()), 318 json_web_key: Some(dpop_jwk), 319 key_id: None, 320 }; 321 322 let auth = access_token.map(challenge); 323 let issued_at = Some(now.timestamp() as u64); 324 let expiration = Some((now + chrono::Duration::seconds(30)).timestamp() as u64); 325 326 let claims = Claims::new(JoseClaims { 327 auth, 328 expiration, 329 http_method: Some(http_method.to_string()), 330 http_uri: Some(http_uri.to_string()), 331 issued_at, 332 json_web_token_id: Some(Ulid::new().to_string()), 333 ..Default::default() 334 }); 335 336 let token = mint(key_data, &header, &claims)?; 337 338 Ok((token, header, claims)) 339} 340 341/// Extracts the JWK thumbprint from a DPoP JWT. 342/// 343/// This function parses a DPoP JWT, extracts the JWK from the JWT header, 344/// and computes the RFC 7638 thumbprint of that JWK. The thumbprint can 345/// be used to uniquely identify the key used in the DPoP proof. 346/// 347/// # Arguments 348/// * `dpop_jwt` - The DPoP JWT token as a string 349/// 350/// # Returns 351/// * `Ok(String)` - The base64url-encoded SHA-256 thumbprint of the JWK 352/// * `Err(anyhow::Error)` - If JWT parsing, JWK extraction, or thumbprint calculation fails 353/// 354/// # Errors 355/// This function will return an error if: 356/// - The JWT format is invalid (not 3 parts separated by dots) 357/// - The JWT header cannot be base64 decoded or parsed as JSON 358/// - The header does not contain a "jwk" field 359/// - The JWK cannot be converted to the required format 360/// - Thumbprint calculation fails 361/// 362/// # Examples 363/// ``` 364/// use atproto_oauth::dpop::extract_jwk_thumbprint; 365/// 366/// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ"; 367/// let thumbprint = extract_jwk_thumbprint(dpop_jwt)?; 368/// assert_eq!(thumbprint.len(), 43); // SHA-256 base64url is 43 characters 369/// # Ok::<(), Box<dyn std::error::Error>>(()) 370/// ``` 371pub fn extract_jwk_thumbprint(dpop_jwt: &str) -> Result<String> { 372 // Split the JWT into its three parts 373 let parts: Vec<&str> = dpop_jwt.split('.').collect(); 374 if parts.len() != 3 { 375 return Err(JWTError::InvalidFormat.into()); 376 } 377 378 let encoded_header = parts[0]; 379 380 // Decode the header 381 let header_bytes = URL_SAFE_NO_PAD 382 .decode(encoded_header) 383 .map_err(|_| JWTError::InvalidHeader)?; 384 385 // Parse the header as JSON 386 let header_json: serde_json::Value = 387 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?; 388 389 // Extract the JWK from the header 390 let jwk_value = header_json 391 .get("jwk") 392 .ok_or_else(|| JWTError::MissingClaim { 393 claim: "jwk".to_string(), 394 })?; 395 396 // Filter the JWK to only include core fields that JwkEcKey expects 397 let jwk_object = jwk_value 398 .as_object() 399 .ok_or_else(|| JWKError::MissingField { 400 field: "jwk object".to_string(), 401 })?; 402 403 // Extract only the core JWK fields that JwkEcKey supports 404 let mut filtered_jwk = serde_json::Map::new(); 405 for field in ["kty", "crv", "x", "y", "d"] { 406 if let Some(value) = jwk_object.get(field) { 407 filtered_jwk.insert(field.to_string(), value.clone()); 408 } 409 } 410 411 // Convert the filtered JWK JSON to a JwkEcKey 412 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk)) 413 .map_err(|e| JWKError::SerializationError { 414 message: e.to_string(), 415 })?; 416 417 // Create a WrappedJsonWebKey to use with the thumbprint function 418 let wrapped_jwk = WrappedJsonWebKey { 419 kid: None, // We don't need the kid for thumbprint calculation 420 alg: None, // We don't need the alg for thumbprint calculation 421 _use: None, // We don't need the use for thumbprint calculation 422 jwk: jwk_ec_key, 423 }; 424 425 // Calculate and return the thumbprint 426 thumbprint(&wrapped_jwk).map_err(|e| e.into()) 427} 428 429/// Configuration for DPoP JWT validation. 430/// 431/// This struct allows callers to specify what aspects of the DPoP JWT should be validated. 432#[cfg_attr(debug_assertions, derive(Debug))] 433#[derive(Clone)] 434pub struct DpopValidationConfig { 435 /// Expected HTTP method (e.g., "POST", "GET"). If None, method validation is skipped. 436 pub expected_http_method: Option<String>, 437 /// Expected HTTP URI. If None, URI validation is skipped. 438 pub expected_http_uri: Option<String>, 439 /// Expected access token hash. If Some, the `ath` claim must match this value. 440 pub expected_access_token_hash: Option<String>, 441 /// Maximum age of the token in seconds. Default is 60 seconds. 442 pub max_age_seconds: u64, 443 /// Whether to allow tokens with future `iat` times (for clock skew tolerance). 444 pub allow_future_iat: bool, 445 /// Clock skew tolerance in seconds (default 30 seconds). 446 pub clock_skew_tolerance_seconds: u64, 447 /// Array of valid nonce values. If not empty, the `nonce` claim must be present and match one of these values. 448 pub expected_nonce_values: Vec<String>, 449 /// Current timestamp for validation purposes. 450 pub now: i64, 451} 452 453impl Default for DpopValidationConfig { 454 fn default() -> Self { 455 let now = chrono::Utc::now().timestamp(); 456 Self { 457 expected_http_method: None, 458 expected_http_uri: None, 459 expected_access_token_hash: None, 460 max_age_seconds: 60, 461 allow_future_iat: false, 462 clock_skew_tolerance_seconds: 30, 463 expected_nonce_values: Vec::new(), 464 now, 465 } 466 } 467} 468 469impl DpopValidationConfig { 470 /// Create a new validation config for authorization requests (no access token hash required). 471 pub fn for_authorization(http_method: &str, http_uri: &str) -> Self { 472 Self { 473 expected_http_method: Some(http_method.to_string()), 474 expected_http_uri: Some(http_uri.to_string()), 475 expected_access_token_hash: None, 476 ..Default::default() 477 } 478 } 479 480 /// Create a new validation config for resource requests (access token hash required). 481 pub fn for_resource_request(http_method: &str, http_uri: &str, access_token: &str) -> Self { 482 Self { 483 expected_http_method: Some(http_method.to_string()), 484 expected_http_uri: Some(http_uri.to_string()), 485 expected_access_token_hash: Some(challenge(access_token)), 486 ..Default::default() 487 } 488 } 489} 490 491/// Validates a DPoP JWT and returns the JWK thumbprint if validation succeeds. 492/// 493/// This function performs comprehensive validation of a DPoP JWT including: 494/// - JWT structure and format validation 495/// - Header validation (typ, alg, jwk fields) 496/// - Claims validation (jti, htm, htu, iat, and optionally ath and nonce) 497/// - Cryptographic signature verification using the embedded JWK 498/// - Timestamp validation with configurable tolerances 499/// - Nonce validation against expected values (if configured) 500/// 501/// # Arguments 502/// * `dpop_jwt` - The DPoP JWT token as a string 503/// * `config` - Validation configuration specifying what to validate 504/// 505/// # Returns 506/// * `Ok(String)` - The base64url-encoded SHA-256 thumbprint of the validated JWK 507/// * `Err(anyhow::Error)` - If any validation step fails 508/// 509/// # Errors 510/// This function will return an error if: 511/// - The JWT format is invalid 512/// - Required header fields are missing or invalid 513/// - Required claims are missing or invalid 514/// - The signature verification fails 515/// - Timestamp validation fails 516/// - HTTP method or URI don't match expected values 517/// 518/// # Examples 519/// ```no_run 520/// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig}; 521/// 522/// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ"; 523/// let mut config = DpopValidationConfig::for_authorization("POST", "https://aipdev.tunn.dev/oauth/token"); 524/// config.max_age_seconds = 9000000; 525/// let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?; 526/// assert_eq!(thumbprint.len(), 43); // SHA-256 base64url is 43 characters 527/// # Ok::<(), Box<dyn std::error::Error>>(()) 528/// ``` 529pub fn validate_dpop_jwt(dpop_jwt: &str, config: &DpopValidationConfig) -> Result<String> { 530 // Split the JWT into its three parts 531 let parts: Vec<&str> = dpop_jwt.split('.').collect(); 532 if parts.len() != 3 { 533 return Err(JWTError::InvalidFormat.into()); 534 } 535 536 let (encoded_header, encoded_payload, encoded_signature) = (parts[0], parts[1], parts[2]); 537 538 // 1. DECODE AND VALIDATE HEADER 539 let header_bytes = URL_SAFE_NO_PAD 540 .decode(encoded_header) 541 .map_err(|_| JWTError::InvalidHeader)?; 542 543 let header_json: serde_json::Value = 544 serde_json::from_slice(&header_bytes).map_err(|_| JWTError::InvalidHeader)?; 545 546 // Validate typ field 547 let typ = header_json 548 .get("typ") 549 .and_then(|v| v.as_str()) 550 .ok_or_else(|| JWTError::MissingClaim { 551 claim: "typ".to_string(), 552 })?; 553 554 if typ != "dpop+jwt" { 555 return Err(JWTError::InvalidTokenType { 556 expected: "dpop+jwt".to_string(), 557 actual: typ.to_string(), 558 } 559 .into()); 560 } 561 562 // Validate alg field 563 let alg = header_json 564 .get("alg") 565 .and_then(|v| v.as_str()) 566 .ok_or_else(|| JWTError::MissingClaim { 567 claim: "alg".to_string(), 568 })?; 569 570 if !matches!(alg, "ES256" | "ES384" | "ES256K") { 571 return Err(JWTError::UnsupportedAlgorithm { 572 algorithm: alg.to_string(), 573 key_type: "EC".to_string(), 574 } 575 .into()); 576 } 577 578 // Extract and validate JWK 579 let jwk_value = header_json 580 .get("jwk") 581 .ok_or_else(|| JWTError::MissingClaim { 582 claim: "jwk".to_string(), 583 })?; 584 585 let jwk_object = jwk_value 586 .as_object() 587 .ok_or_else(|| JWKError::MissingField { 588 field: "jwk object".to_string(), 589 })?; 590 591 // Filter the JWK to only include core fields that JwkEcKey expects 592 let mut filtered_jwk = serde_json::Map::new(); 593 for field in ["kty", "crv", "x", "y", "d"] { 594 if let Some(value) = jwk_object.get(field) { 595 filtered_jwk.insert(field.to_string(), value.clone()); 596 } 597 } 598 599 let jwk_ec_key: JwkEcKey = serde_json::from_value(serde_json::Value::Object(filtered_jwk)) 600 .map_err(|e| JWKError::SerializationError { 601 message: e.to_string(), 602 })?; 603 604 // Create WrappedJsonWebKey for further operations 605 let wrapped_jwk = WrappedJsonWebKey { 606 kid: None, 607 alg: Some(alg.to_string()), 608 _use: Some("sig".to_string()), 609 jwk: jwk_ec_key, 610 }; 611 612 // Convert JWK to KeyData for signature verification 613 let key_data = to_key_data(&wrapped_jwk)?; 614 615 // 2. DECODE AND VALIDATE PAYLOAD/CLAIMS 616 let payload_bytes = URL_SAFE_NO_PAD 617 .decode(encoded_payload) 618 .map_err(|_| JWTError::InvalidPayload)?; 619 620 let claims: serde_json::Value = 621 serde_json::from_slice(&payload_bytes).map_err(|_| JWTError::InvalidPayloadJson)?; 622 623 // Validate required claims 624 // jti (JWT ID) - required for replay protection 625 claims 626 .get("jti") 627 .and_then(|v| v.as_str()) 628 .ok_or_else(|| JWTError::MissingClaim { 629 claim: "jti".to_string(), 630 })?; 631 632 if let Some(expected_method) = &config.expected_http_method { 633 // htm (HTTP method) - validate if expected method is specified 634 let htm = 635 claims 636 .get("htm") 637 .and_then(|v| v.as_str()) 638 .ok_or_else(|| JWTError::MissingClaim { 639 claim: "htm".to_string(), 640 })?; 641 642 if htm != expected_method { 643 return Err(JWTError::HttpMethodMismatch { 644 expected: expected_method.clone(), 645 actual: htm.to_string(), 646 } 647 .into()); 648 } 649 } 650 651 if let Some(expected_uri) = &config.expected_http_uri { 652 // htu (HTTP URI) - validate if expected URI is specified 653 let htu = 654 claims 655 .get("htu") 656 .and_then(|v| v.as_str()) 657 .ok_or_else(|| JWTError::MissingClaim { 658 claim: "htu".to_string(), 659 })?; 660 661 if htu != expected_uri { 662 return Err(JWTError::HttpUriMismatch { 663 expected: expected_uri.clone(), 664 actual: htu.to_string(), 665 } 666 .into()); 667 } 668 } 669 670 // iat (issued at) - validate timestamp 671 let iat = claims 672 .get("iat") 673 .and_then(|v| v.as_u64()) 674 .ok_or_else(|| JWTError::MissingClaim { 675 claim: "iat".to_string(), 676 })?; 677 678 // Check if token is too old 679 if config.now as u64 > iat + config.max_age_seconds + config.clock_skew_tolerance_seconds { 680 return Err(JWTError::InvalidTimestamp { 681 reason: format!( 682 "Token too old: issued at {} but max age is {} seconds", 683 iat, config.max_age_seconds 684 ), 685 } 686 .into()); 687 } 688 689 // Check if token is from the future (unless allowed) 690 if !config.allow_future_iat && iat > config.now as u64 + config.clock_skew_tolerance_seconds { 691 return Err(JWTError::InvalidTimestamp { 692 reason: format!( 693 "Token from future: issued at {} but current time is {}", 694 iat, config.now 695 ), 696 } 697 .into()); 698 } 699 700 // ath (access token hash) - validate if required 701 if let Some(expected_ath) = &config.expected_access_token_hash { 702 let ath = 703 claims 704 .get("ath") 705 .and_then(|v| v.as_str()) 706 .ok_or_else(|| JWTError::MissingClaim { 707 claim: "ath".to_string(), 708 })?; 709 710 if ath != expected_ath { 711 return Err(JWTError::AccessTokenHashMismatch.into()); 712 } 713 } 714 715 // nonce - validate if required 716 if !config.expected_nonce_values.is_empty() { 717 let nonce = claims 718 .get("nonce") 719 .and_then(|v| v.as_str()) 720 .ok_or_else(|| JWTError::MissingClaim { 721 claim: "nonce".to_string(), 722 })?; 723 724 if !config.expected_nonce_values.contains(&nonce.to_string()) { 725 return Err(JWTError::InvalidNonce { 726 nonce: nonce.to_string(), 727 } 728 .into()); 729 } 730 } 731 732 // exp (expiration) - validate if present 733 if let Some(exp_value) = claims.get("exp") 734 && let Some(exp) = exp_value.as_u64() 735 && config.now as u64 >= exp 736 { 737 return Err(JWTError::TokenExpired.into()); 738 } 739 740 // 3. VERIFY SIGNATURE 741 let content = format!("{}.{}", encoded_header, encoded_payload); 742 let signature_bytes = URL_SAFE_NO_PAD 743 .decode(encoded_signature) 744 .map_err(|_| JWTError::InvalidSignature)?; 745 746 validate(&key_data, &signature_bytes, content.as_bytes()) 747 .map_err(|_| JWTError::SignatureVerificationFailed)?; 748 749 // 4. CALCULATE AND RETURN JWK THUMBPRINT 750 thumbprint(&wrapped_jwk).map_err(|e| e.into()) 751} 752 753#[cfg(test)] 754mod tests { 755 use super::*; 756 757 #[test] 758 fn test_is_dpop_error_invalid_dpop_proof() { 759 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="invalid_dpop_proof", error_description="DPoP proof required""#; 760 assert!(is_dpop_error(header)); 761 } 762 763 #[test] 764 fn test_is_dpop_error_use_dpop_nonce() { 765 let header = r#"DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof""#; 766 assert!(is_dpop_error(header)); 767 } 768 769 #[test] 770 fn test_is_dpop_error_other_error() { 771 let header = 772 r#"DPoP algs="ES256", error="invalid_token", error_description="Token is invalid""#; 773 assert!(!is_dpop_error(header)); 774 } 775 776 #[test] 777 fn test_is_dpop_error_no_error_field() { 778 let header = r#"DPoP algs="ES256", error_description="Some description""#; 779 assert!(!is_dpop_error(header)); 780 } 781 782 #[test] 783 fn test_is_dpop_error_not_dpop_header() { 784 let header = r#"Bearer error="invalid_token""#; 785 assert!(!is_dpop_error(header)); 786 } 787 788 #[test] 789 fn test_is_dpop_error_empty_string() { 790 assert!(!is_dpop_error("")); 791 } 792 793 #[test] 794 fn test_is_dpop_error_minimal_valid() { 795 let header = r#"DPoP error="invalid_dpop_proof""#; 796 assert!(is_dpop_error(header)); 797 } 798 799 #[test] 800 fn test_is_dpop_error_unquoted_value() { 801 let header = r#"DPoP error=invalid_dpop_proof"#; 802 assert!(is_dpop_error(header)); 803 } 804 805 #[test] 806 fn test_is_dpop_error_whitespace_handling() { 807 let header = 808 r#" DPoP algs="ES256" , error="use_dpop_nonce" , error_description="test" "#; 809 assert!(is_dpop_error(header)); 810 } 811 812 #[test] 813 fn test_is_dpop_error_case_sensitive_scheme() { 814 let header = r#"dpop error="invalid_dpop_proof""#; 815 assert!(!is_dpop_error(header)); 816 } 817 818 #[test] 819 fn test_is_dpop_error_case_sensitive_error_value() { 820 let header = r#"DPoP error="INVALID_DPOP_PROOF""#; 821 assert!(!is_dpop_error(header)); 822 } 823 824 #[test] 825 fn test_is_dpop_error_malformed_quotes() { 826 let header = r#"DPoP error="invalid_dpop_proof"#; 827 assert!(is_dpop_error(header)); 828 } 829 830 #[test] 831 fn test_is_dpop_error_multiple_error_fields() { 832 let header = r#"DPoP error="invalid_token", algs="ES256", error="invalid_dpop_proof""#; 833 // Should match the first error field found 834 assert!(!is_dpop_error(header)); 835 } 836 837 #[test] 838 fn test_extract_jwk_thumbprint_with_known_jwt() -> anyhow::Result<()> { 839 // Test with the provided DPoP JWT 840 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ"; 841 842 let thumbprint = extract_jwk_thumbprint(dpop_jwt)?; 843 844 // Verify the thumbprint is a valid base64url string 845 assert_eq!(thumbprint.len(), 43); // SHA-256 base64url encoded is 43 characters 846 assert!(!thumbprint.contains('=')); // No padding in base64url 847 assert!(!thumbprint.contains('+')); // No + in base64url 848 assert!(!thumbprint.contains('/')); // No / in base64url 849 850 // Verify that the thumbprint is deterministic (same result every time) 851 let thumbprint2 = extract_jwk_thumbprint(dpop_jwt)?; 852 assert_eq!(thumbprint, thumbprint2); 853 854 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU"); 855 856 Ok(()) 857 } 858 859 #[test] 860 fn test_extract_jwk_thumbprint_invalid_jwt_format() { 861 // Test with invalid JWT format (not 3 parts) 862 let invalid_jwt = "invalid.jwt"; 863 let result = extract_jwk_thumbprint(invalid_jwt); 864 assert!(result.is_err()); 865 assert!(result.unwrap_err().to_string().contains("expected 3 parts")); 866 } 867 868 #[test] 869 fn test_extract_jwk_thumbprint_invalid_header() { 870 // Test with invalid base64 in header 871 let invalid_jwt = "invalid-base64.eyJqdGkiOiJ0ZXN0In0.signature"; 872 let result = extract_jwk_thumbprint(invalid_jwt); 873 assert!(result.is_err()); 874 assert!( 875 result 876 .unwrap_err() 877 .to_string() 878 .contains("Invalid JWT header") 879 ); 880 } 881 882 #[test] 883 fn test_extract_jwk_thumbprint_missing_jwk() -> anyhow::Result<()> { 884 // Create a valid JWT header without a JWK field 885 let header = serde_json::json!({ 886 "alg": "ES256", 887 "typ": "dpop+jwt" 888 }); 889 let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD 890 .encode(serde_json::to_string(&header)?.as_bytes()); 891 892 let jwt_without_jwk = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header); 893 let result = extract_jwk_thumbprint(&jwt_without_jwk); 894 assert!(result.is_err()); 895 assert!( 896 result 897 .unwrap_err() 898 .to_string() 899 .contains("Missing required claim: jwk") 900 ); 901 902 Ok(()) 903 } 904 905 #[test] 906 fn test_extract_jwk_thumbprint_with_generated_dpop() -> anyhow::Result<()> { 907 // Test with a DPoP JWT generated by our own functions 908 use atproto_identity::key::{KeyType, generate_key}; 909 910 let key_data = generate_key(KeyType::P256Private)?; 911 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 912 913 let thumbprint = extract_jwk_thumbprint(&dpop_token)?; 914 915 // Verify the thumbprint properties 916 assert_eq!(thumbprint.len(), 43); 917 assert!(!thumbprint.contains('=')); 918 assert!(!thumbprint.contains('+')); 919 assert!(!thumbprint.contains('/')); 920 921 // Verify deterministic behavior 922 let thumbprint2 = extract_jwk_thumbprint(&dpop_token)?; 923 assert_eq!(thumbprint, thumbprint2); 924 925 Ok(()) 926 } 927 928 #[test] 929 fn test_extract_jwk_thumbprint_different_keys_different_thumbprints() -> anyhow::Result<()> { 930 // Test that different keys produce different thumbprints 931 use atproto_identity::key::{KeyType, generate_key}; 932 933 let key1 = generate_key(KeyType::P256Private)?; 934 let key2 = generate_key(KeyType::P256Private)?; 935 936 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?; 937 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?; 938 939 let thumbprint1 = extract_jwk_thumbprint(&dpop_token1)?; 940 let thumbprint2 = extract_jwk_thumbprint(&dpop_token2)?; 941 942 assert_ne!(thumbprint1, thumbprint2); 943 944 Ok(()) 945 } 946 947 // === DPOP VALIDATION TESTS === 948 949 #[test] 950 fn test_validate_dpop_jwt_with_known_jwt() -> anyhow::Result<()> { 951 // Test with the provided DPoP JWT - but this will fail timestamp validation 952 // since the iat is from 2024. We'll use a permissive config. 953 let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ"; 954 955 // Use a very permissive config to account for old timestamp 956 let config = DpopValidationConfig { 957 expected_http_method: Some("POST".to_string()), 958 expected_http_uri: Some("https://aipdev.tunn.dev/oauth/token".to_string()), 959 expected_access_token_hash: None, 960 max_age_seconds: 365 * 24 * 60 * 60, // 1 year 961 allow_future_iat: true, 962 clock_skew_tolerance_seconds: 365 * 24 * 60 * 60, // 1 year 963 expected_nonce_values: Vec::new(), 964 now: 1, 965 }; 966 967 let thumbprint = validate_dpop_jwt(dpop_jwt, &config)?; 968 969 // Verify the thumbprint matches what we expect 970 assert_eq!(thumbprint, "lkqoPsebYpatLxSM4WCfAvOHqxU7X5RKlg-0_cobwSU"); 971 assert_eq!(thumbprint.len(), 43); 972 973 Ok(()) 974 } 975 976 #[test] 977 fn test_validate_dpop_jwt_with_generated_jwt() -> anyhow::Result<()> { 978 // Test with a freshly generated DPoP JWT 979 use atproto_identity::key::{KeyType, generate_key}; 980 981 let key_data = generate_key(KeyType::P256Private)?; 982 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 983 984 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token"); 985 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?; 986 987 // Verify the thumbprint properties 988 assert_eq!(thumbprint.len(), 43); 989 assert!(!thumbprint.contains('=')); 990 assert!(!thumbprint.contains('+')); 991 assert!(!thumbprint.contains('/')); 992 993 Ok(()) 994 } 995 996 #[test] 997 fn test_validate_dpop_jwt_invalid_format() { 998 let config = DpopValidationConfig::default(); 999 1000 // Test invalid JWT format 1001 let result = validate_dpop_jwt("invalid.jwt", &config); 1002 assert!(result.is_err()); 1003 assert!(result.unwrap_err().to_string().contains("expected 3 parts")); 1004 } 1005 1006 #[test] 1007 fn test_validate_dpop_jwt_invalid_typ() -> anyhow::Result<()> { 1008 // Create a JWT with wrong typ field 1009 let header = serde_json::json!({ 1010 "alg": "ES256", 1011 "typ": "JWT", // Wrong type, should be "dpop+jwt" 1012 "jwk": { 1013 "kty": "EC", 1014 "crv": "P-256", 1015 "x": "test", 1016 "y": "test" 1017 } 1018 }); 1019 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes()); 1020 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header); 1021 1022 let config = DpopValidationConfig::default(); 1023 let result = validate_dpop_jwt(&jwt, &config); 1024 assert!(result.is_err()); 1025 assert!( 1026 result 1027 .unwrap_err() 1028 .to_string() 1029 .contains("Invalid token type") 1030 ); 1031 1032 Ok(()) 1033 } 1034 1035 #[test] 1036 fn test_validate_dpop_jwt_unsupported_algorithm() -> anyhow::Result<()> { 1037 // Create a JWT with unsupported algorithm 1038 let header = serde_json::json!({ 1039 "alg": "HS256", // Unsupported algorithm 1040 "typ": "dpop+jwt", 1041 "jwk": { 1042 "kty": "EC", 1043 "crv": "P-256", 1044 "x": "test", 1045 "y": "test" 1046 } 1047 }); 1048 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes()); 1049 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header); 1050 1051 let config = DpopValidationConfig::default(); 1052 let result = validate_dpop_jwt(&jwt, &config); 1053 assert!(result.is_err()); 1054 assert!( 1055 result 1056 .unwrap_err() 1057 .to_string() 1058 .contains("Unsupported JWT algorithm") 1059 ); 1060 1061 Ok(()) 1062 } 1063 1064 #[test] 1065 fn test_validate_dpop_jwt_missing_jwk() -> anyhow::Result<()> { 1066 // Create a JWT without JWK field 1067 let header = serde_json::json!({ 1068 "alg": "ES256", 1069 "typ": "dpop+jwt" 1070 // Missing jwk field 1071 }); 1072 let encoded_header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes()); 1073 let jwt = format!("{}.eyJqdGkiOiJ0ZXN0In0.signature", encoded_header); 1074 1075 let config = DpopValidationConfig::default(); 1076 let result = validate_dpop_jwt(&jwt, &config); 1077 assert!(result.is_err()); 1078 assert!( 1079 result 1080 .unwrap_err() 1081 .to_string() 1082 .contains("Missing required claim: jwk") 1083 ); 1084 1085 Ok(()) 1086 } 1087 1088 #[test] 1089 fn test_validate_dpop_jwt_missing_claims() -> anyhow::Result<()> { 1090 use atproto_identity::key::{KeyType, generate_key}; 1091 1092 let key_data = generate_key(KeyType::P256Private)?; 1093 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1094 1095 // Modify the token to remove jti claim 1096 let parts: Vec<&str> = dpop_token.split('.').collect(); 1097 let payload = serde_json::json!({ 1098 // Missing jti 1099 "htm": "POST", 1100 "htu": "https://example.com/token", 1101 "iat": chrono::Utc::now().timestamp() 1102 }); 1103 let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes()); 1104 let modified_jwt = format!("{}.{}.{}", parts[0], encoded_payload, parts[2]); 1105 1106 let config = DpopValidationConfig::default(); 1107 let result = validate_dpop_jwt(&modified_jwt, &config); 1108 assert!(result.is_err()); 1109 assert!( 1110 result 1111 .unwrap_err() 1112 .to_string() 1113 .contains("Missing required claim: jti") 1114 ); 1115 1116 Ok(()) 1117 } 1118 1119 #[test] 1120 fn test_validate_dpop_jwt_http_method_mismatch() -> anyhow::Result<()> { 1121 use atproto_identity::key::{KeyType, generate_key}; 1122 1123 let key_data = generate_key(KeyType::P256Private)?; 1124 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1125 1126 // Config expects GET but token has POST 1127 let config = DpopValidationConfig::for_authorization("GET", "https://example.com/token"); 1128 let result = validate_dpop_jwt(&dpop_token, &config); 1129 assert!(result.is_err()); 1130 assert!( 1131 result 1132 .unwrap_err() 1133 .to_string() 1134 .contains("HTTP method mismatch") 1135 ); 1136 1137 Ok(()) 1138 } 1139 1140 #[test] 1141 fn test_validate_dpop_jwt_http_uri_mismatch() -> anyhow::Result<()> { 1142 use atproto_identity::key::{KeyType, generate_key}; 1143 1144 let key_data = generate_key(KeyType::P256Private)?; 1145 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1146 1147 // Config expects different URI 1148 let config = DpopValidationConfig::for_authorization("POST", "https://different.com/token"); 1149 let result = validate_dpop_jwt(&dpop_token, &config); 1150 assert!(result.is_err()); 1151 assert!( 1152 result 1153 .unwrap_err() 1154 .to_string() 1155 .contains("HTTP URI mismatch") 1156 ); 1157 1158 Ok(()) 1159 } 1160 1161 #[test] 1162 fn test_validate_dpop_jwt_require_access_token_hash() -> anyhow::Result<()> { 1163 use atproto_identity::key::{KeyType, generate_key}; 1164 1165 let key_data = generate_key(KeyType::P256Private)?; 1166 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1167 1168 // Config requires ath but auth_dpop doesn't include it 1169 let config = DpopValidationConfig::for_resource_request( 1170 "POST", 1171 "https://example.com/token", 1172 "access_token", 1173 ); 1174 let result = validate_dpop_jwt(&dpop_token, &config); 1175 assert!(result.is_err()); 1176 assert!( 1177 result 1178 .unwrap_err() 1179 .to_string() 1180 .contains("Missing required claim: ath") 1181 ); 1182 1183 Ok(()) 1184 } 1185 1186 #[test] 1187 fn test_validate_dpop_jwt_with_resource_request() -> anyhow::Result<()> { 1188 use atproto_identity::key::{KeyType, generate_key}; 1189 1190 let key_data = generate_key(KeyType::P256Private)?; 1191 let access_token = "test_access_token"; 1192 let (dpop_token, _, _) = request_dpop( 1193 &key_data, 1194 "GET", 1195 "https://example.com/resource", 1196 access_token, 1197 )?; 1198 1199 // Config for resource request (requires ath) 1200 let config = DpopValidationConfig::for_resource_request( 1201 "GET", 1202 "https://example.com/resource", 1203 access_token, 1204 ); 1205 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?; 1206 1207 assert_eq!(thumbprint.len(), 43); 1208 1209 Ok(()) 1210 } 1211 1212 #[test] 1213 fn test_validate_dpop_jwt_timestamp_validation() -> anyhow::Result<()> { 1214 use atproto_identity::key::{KeyType, generate_key}; 1215 1216 let key_data = generate_key(KeyType::P256Private)?; 1217 1218 // Create a token with old timestamp 1219 let old_time = chrono::Utc::now().timestamp() as u64 - 3600; // 1 hour ago 1220 1221 let public_key_data = to_public(&key_data)?; 1222 let dpop_jwk: JwkEcKey = (&public_key_data).try_into()?; 1223 1224 let header = Header { 1225 type_: Some("dpop+jwt".to_string()), 1226 algorithm: Some("ES256".to_string()), 1227 json_web_key: Some(dpop_jwk), 1228 key_id: None, 1229 }; 1230 1231 let claims = Claims::new(JoseClaims { 1232 json_web_token_id: Some(Ulid::new().to_string()), 1233 http_method: Some("POST".to_string()), 1234 http_uri: Some("https://example.com/token".to_string()), 1235 issued_at: Some(old_time), 1236 ..Default::default() 1237 }); 1238 1239 let old_token = mint(&key_data, &header, &claims)?; 1240 1241 // Use strict config with short max_age 1242 let config = DpopValidationConfig { 1243 expected_http_method: Some("POST".to_string()), 1244 expected_http_uri: Some("https://example.com/token".to_string()), 1245 max_age_seconds: 60, // 1 minute 1246 ..Default::default() 1247 }; 1248 1249 let result = validate_dpop_jwt(&old_token, &config); 1250 assert!(result.is_err()); 1251 assert!(result.unwrap_err().to_string().contains("Token too old")); 1252 1253 Ok(()) 1254 } 1255 1256 #[test] 1257 fn test_validate_dpop_jwt_different_keys_different_thumbprints() -> anyhow::Result<()> { 1258 use atproto_identity::key::{KeyType, generate_key}; 1259 1260 let key1 = generate_key(KeyType::P256Private)?; 1261 let key2 = generate_key(KeyType::P256Private)?; 1262 1263 let (dpop_token1, _, _) = auth_dpop(&key1, "POST", "https://example.com/token")?; 1264 let (dpop_token2, _, _) = auth_dpop(&key2, "POST", "https://example.com/token")?; 1265 1266 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token"); 1267 1268 let thumbprint1 = validate_dpop_jwt(&dpop_token1, &config)?; 1269 let thumbprint2 = validate_dpop_jwt(&dpop_token2, &config)?; 1270 1271 assert_ne!(thumbprint1, thumbprint2); 1272 1273 Ok(()) 1274 } 1275 1276 #[test] 1277 fn test_validate_dpop_jwt_config_for_authorization() { 1278 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/auth"); 1279 1280 assert_eq!(config.expected_http_method, Some("POST".to_string())); 1281 assert_eq!( 1282 config.expected_http_uri, 1283 Some("https://example.com/auth".to_string()) 1284 ); 1285 assert_eq!(config.max_age_seconds, 60); 1286 assert!(!config.allow_future_iat); 1287 assert_eq!(config.clock_skew_tolerance_seconds, 30); 1288 } 1289 1290 #[test] 1291 fn test_validate_dpop_jwt_config_for_resource_request() { 1292 let config = DpopValidationConfig::for_resource_request( 1293 "GET", 1294 "https://example.com/resource", 1295 "access_token", 1296 ); 1297 1298 assert_eq!(config.expected_http_method, Some("GET".to_string())); 1299 assert_eq!( 1300 config.expected_http_uri, 1301 Some("https://example.com/resource".to_string()) 1302 ); 1303 assert_eq!(config.max_age_seconds, 60); 1304 assert!(!config.allow_future_iat); 1305 assert_eq!(config.clock_skew_tolerance_seconds, 30); 1306 } 1307 1308 #[test] 1309 fn test_validate_dpop_jwt_permissive_config() -> anyhow::Result<()> { 1310 use atproto_identity::key::{KeyType, generate_key}; 1311 1312 let key_data = generate_key(KeyType::P256Private)?; 1313 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1314 1315 let now = chrono::Utc::now().timestamp(); 1316 1317 // Very permissive config - doesn't validate method/URI 1318 let config = DpopValidationConfig { 1319 expected_http_method: None, 1320 expected_http_uri: None, 1321 expected_access_token_hash: None, 1322 max_age_seconds: 3600, 1323 allow_future_iat: true, 1324 clock_skew_tolerance_seconds: 300, 1325 expected_nonce_values: Vec::new(), 1326 now, 1327 }; 1328 1329 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?; 1330 assert_eq!(thumbprint.len(), 43); 1331 1332 Ok(()) 1333 } 1334 1335 #[test] 1336 fn test_validate_dpop_jwt_nonce_validation_success() -> anyhow::Result<()> { 1337 use atproto_identity::key::{KeyType, generate_key}; 1338 1339 let key_data = generate_key(KeyType::P256Private)?; 1340 1341 // Create a DPoP token with a nonce by manually building it 1342 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1343 1344 // Add nonce to claims 1345 let test_nonce = "test_nonce_12345"; 1346 claims 1347 .private 1348 .insert("nonce".to_string(), test_nonce.into()); 1349 1350 // Create the token with nonce 1351 let dpop_token = mint(&key_data, &header, &claims)?; 1352 1353 // Create config with expected nonce values 1354 let mut config = 1355 DpopValidationConfig::for_authorization("POST", "https://example.com/token"); 1356 config.expected_nonce_values = vec![test_nonce.to_string(), "other_nonce".to_string()]; 1357 1358 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?; 1359 assert_eq!(thumbprint.len(), 43); 1360 1361 Ok(()) 1362 } 1363 1364 #[test] 1365 fn test_validate_dpop_jwt_nonce_validation_failure() -> anyhow::Result<()> { 1366 use atproto_identity::key::{KeyType, generate_key}; 1367 1368 let key_data = generate_key(KeyType::P256Private)?; 1369 1370 // Create a DPoP token with a specific nonce 1371 let (_, header, mut claims) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1372 1373 // Add a nonce that won't match the expected values 1374 let token_nonce = "token_nonce_that_wont_match"; 1375 claims 1376 .private 1377 .insert("nonce".to_string(), token_nonce.into()); 1378 1379 // Create the token with nonce 1380 let dpop_token = mint(&key_data, &header, &claims)?; 1381 1382 // Create config with different nonce values (not matching the token) 1383 let mut config = 1384 DpopValidationConfig::for_authorization("POST", "https://example.com/token"); 1385 config.expected_nonce_values = vec![ 1386 "expected_nonce_1".to_string(), 1387 "expected_nonce_2".to_string(), 1388 ]; 1389 1390 let result = validate_dpop_jwt(&dpop_token, &config); 1391 assert!(result.is_err()); 1392 let error_msg = result.unwrap_err().to_string(); 1393 assert!(error_msg.contains("Invalid nonce")); 1394 1395 Ok(()) 1396 } 1397 1398 #[test] 1399 fn test_validate_dpop_jwt_nonce_missing_when_required() -> anyhow::Result<()> { 1400 use atproto_identity::key::{KeyType, generate_key}; 1401 1402 let key_data = generate_key(KeyType::P256Private)?; 1403 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1404 1405 // Modify the token to remove the nonce claim 1406 let parts: Vec<&str> = dpop_token.split('.').collect(); 1407 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?; 1408 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes)?; 1409 1410 // Remove the nonce field 1411 payload.as_object_mut().unwrap().remove("nonce"); 1412 1413 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes()); 1414 let modified_jwt = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 1415 1416 // Create config that requires nonce validation 1417 let mut config = 1418 DpopValidationConfig::for_authorization("POST", "https://example.com/token"); 1419 config.expected_nonce_values = vec!["required_nonce".to_string()]; 1420 1421 let result = validate_dpop_jwt(&modified_jwt, &config); 1422 assert!(result.is_err()); 1423 assert!( 1424 result 1425 .unwrap_err() 1426 .to_string() 1427 .contains("Missing required claim: nonce") 1428 ); 1429 1430 Ok(()) 1431 } 1432 1433 #[test] 1434 fn test_validate_dpop_jwt_nonce_empty_array_skips_validation() -> anyhow::Result<()> { 1435 use atproto_identity::key::{KeyType, generate_key}; 1436 1437 let key_data = generate_key(KeyType::P256Private)?; 1438 let (dpop_token, _, _) = auth_dpop(&key_data, "POST", "https://example.com/token")?; 1439 1440 // Create config with empty nonce values (should skip nonce validation) 1441 let config = DpopValidationConfig::for_authorization("POST", "https://example.com/token"); 1442 assert!(config.expected_nonce_values.is_empty()); 1443 1444 let thumbprint = validate_dpop_jwt(&dpop_token, &config)?; 1445 assert_eq!(thumbprint.len(), 43); 1446 1447 Ok(()) 1448 } 1449}