A better Rust ATProto crate
1//! Identity resolution utilities: DID and handle resolution, DID document fetch, 2//! and helpers for PDS endpoint discovery. See `identity::resolver` for details. 3//! Identity resolution: handle → DID and DID → document, with smart fallbacks. 4//! 5//! Fallback order (default): 6//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC 7//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured). 8//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured), 9//! then Slingshot mini‑doc (partial) if configured. 10//! 11//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 12//! and optionally validate the document `id` against the requested DID. 13 14// use crate::CowStr; // not currently needed directly here 15pub mod resolver; 16 17use crate::resolver::{ 18 DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource, 19 ResolverOptions, 20}; 21use bytes::Bytes; 22use jacquard_api::com_atproto::identity::resolve_did; 23use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle; 24use jacquard_common::error::TransportError; 25use jacquard_common::http_client::HttpClient; 26use jacquard_common::types::did::Did; 27use jacquard_common::types::did_doc::DidDocument; 28use jacquard_common::types::ident::AtIdentifier; 29use jacquard_common::xrpc::XrpcExt; 30use jacquard_common::{IntoStatic, types::string::Handle}; 31use percent_encoding::percent_decode_str; 32use reqwest::StatusCode; 33use url::{ParseError, Url}; 34 35#[cfg(feature = "dns")] 36use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 37 38/// Default resolver implementation with configurable fallback order. 39pub struct JacquardResolver { 40 http: reqwest::Client, 41 opts: ResolverOptions, 42 #[cfg(feature = "dns")] 43 dns: Option<TokioAsyncResolver>, 44} 45 46impl JacquardResolver { 47 /// Create a new instance of the default resolver with all options (except DNS) up front 48 pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self { 49 Self { 50 http, 51 opts, 52 #[cfg(feature = "dns")] 53 dns: None, 54 } 55 } 56 57 #[cfg(feature = "dns")] 58 /// Create a new instance of the default resolver with all options, plus default DNS, up front 59 pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self { 60 Self { 61 http, 62 opts, 63 dns: Some(TokioAsyncResolver::tokio( 64 ResolverConfig::default(), 65 Default::default(), 66 )), 67 } 68 } 69 70 #[cfg(feature = "dns")] 71 /// Add default DNS resolution to the resolver 72 pub fn with_system_dns(mut self) -> Self { 73 self.dns = Some(TokioAsyncResolver::tokio( 74 ResolverConfig::default(), 75 Default::default(), 76 )); 77 self 78 } 79 80 /// Set PLC source (PLC directory or Slingshot) 81 pub fn with_plc_source(mut self, source: PlcSource) -> Self { 82 self.opts.plc_source = source; 83 self 84 } 85 86 /// Enable/disable public unauthenticated fallback for resolveHandle 87 pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self { 88 self.opts.public_fallback_for_handle = enable; 89 self 90 } 91 92 /// Enable/disable doc id validation 93 pub fn with_validate_doc_id(mut self, enable: bool) -> Self { 94 self.opts.validate_doc_id = enable; 95 self 96 } 97 98 /// Construct the well-known HTTPS URL for a `did:web` DID. 99 /// 100 /// - `did:web:example.com` → `https://example.com/.well-known/did.json` 101 /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json` 102 fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 103 // did:web:example.com[:path:segments] 104 let s = did.as_str(); 105 let rest = s 106 .strip_prefix("did:web:") 107 .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 108 let mut parts = rest.split(':'); 109 let host = parts 110 .next() 111 .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 112 let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?; 113 let path: Vec<&str> = parts.collect(); 114 if path.is_empty() { 115 url.set_path(".well-known/did.json"); 116 } else { 117 // Append path segments and did.json 118 let mut segments = url 119 .path_segments_mut() 120 .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?; 121 for seg in path { 122 // Minimally percent-decode each segment per spec guidance 123 let decoded = percent_decode_str(seg).decode_utf8_lossy(); 124 segments.push(&decoded); 125 } 126 segments.push("did.json"); 127 // drop segments 128 } 129 Ok(url) 130 } 131 132 #[cfg(test)] 133 fn test_did_web_url_raw(&self, s: &str) -> String { 134 let did = Did::new(s).unwrap(); 135 self.did_web_url(&did).unwrap().to_string() 136 } 137 138 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 139 let resp = self 140 .http 141 .get(url) 142 .send() 143 .await 144 .map_err(TransportError::from)?; 145 let status = resp.status(); 146 let buf = resp.bytes().await.map_err(TransportError::from)?; 147 Ok((buf, status)) 148 } 149 150 async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 151 let resp = self 152 .http 153 .get(url) 154 .send() 155 .await 156 .map_err(TransportError::from)?; 157 if resp.status() == StatusCode::OK { 158 Ok(resp.text().await.map_err(TransportError::from)?) 159 } else { 160 Err(IdentityError::Http( 161 resp.error_for_status().unwrap_err().into(), 162 )) 163 } 164 } 165 166 #[cfg(feature = "dns")] 167 async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> { 168 let Some(dns) = &self.dns else { 169 return Ok(vec![]); 170 }; 171 let fqdn = format!("_atproto.{name}."); 172 let response = dns.txt_lookup(fqdn).await?; 173 let mut out = Vec::new(); 174 for txt in response.iter() { 175 for data in txt.txt_data().iter() { 176 out.push(String::from_utf8_lossy(data).to_string()); 177 } 178 } 179 Ok(out) 180 } 181 182 fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> { 183 let line = body 184 .lines() 185 .find(|l| !l.trim().is_empty()) 186 .ok_or(IdentityError::InvalidWellKnown)?; 187 let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?; 188 Ok(did.into_static()) 189 } 190} 191 192impl JacquardResolver { 193 /// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default) 194 pub async fn resolve_handle_via_pds( 195 &self, 196 handle: &Handle<'_>, 197 ) -> Result<Did<'static>, IdentityError> { 198 let pds = match &self.opts.pds_fallback { 199 Some(u) => u.clone(), 200 None => return Err(IdentityError::InvalidWellKnown), 201 }; 202 let req = ResolveHandle::new() 203 .handle(handle.clone().into_static()) 204 .build(); 205 let resp = self 206 .http 207 .xrpc(pds) 208 .send(&req) 209 .await 210 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 211 let out = resp 212 .parse() 213 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 214 Did::new_owned(out.did.as_str()) 215 .map(|d| d.into_static()) 216 .map_err(|_| IdentityError::InvalidWellKnown) 217 } 218 219 /// Fetch DID document via PDS resolveDid (returns owned DidDocument) 220 pub async fn fetch_did_doc_via_pds_owned( 221 &self, 222 did: &Did<'_>, 223 ) -> Result<DidDocument<'static>, IdentityError> { 224 let pds = match &self.opts.pds_fallback { 225 Some(u) => u.clone(), 226 None => return Err(IdentityError::InvalidWellKnown), 227 }; 228 let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 229 let resp = self 230 .http 231 .xrpc(pds) 232 .send(&req) 233 .await 234 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 235 let out = resp 236 .parse() 237 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 238 let doc_json = serde_json::to_value(&out.did_doc)?; 239 let s = serde_json::to_string(&doc_json)?; 240 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; 241 Ok(doc_borrowed.into_static()) 242 } 243 244 /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot. 245 /// Returns the raw response wrapper for borrowed parsing and validation. 246 pub async fn fetch_mini_doc_via_slingshot( 247 &self, 248 did: &Did<'_>, 249 ) -> Result<DidDocResponse, IdentityError> { 250 let base = match &self.opts.plc_source { 251 PlcSource::Slingshot { base } => base.clone(), 252 _ => { 253 return Err(IdentityError::UnsupportedDidMethod( 254 "mini-doc requires Slingshot source".into(), 255 )); 256 } 257 }; 258 let mut url = base; 259 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 260 if let Ok(qs) = serde_html_form::to_string( 261 &resolve_did::ResolveDid::new() 262 .did(did.clone().into_static()) 263 .build(), 264 ) { 265 url.set_query(Some(&qs)); 266 } 267 let (buf, status) = self.get_json_bytes(url).await?; 268 Ok(DidDocResponse { 269 buffer: buf, 270 status, 271 requested: Some(did.clone().into_static()), 272 }) 273 } 274} 275 276impl IdentityResolver for JacquardResolver { 277 fn options(&self) -> &ResolverOptions { 278 &self.opts 279 } 280 async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 281 let host = handle.as_str(); 282 for step in &self.opts.handle_order { 283 match step { 284 HandleStep::DnsTxt => { 285 #[cfg(feature = "dns")] 286 { 287 if let Ok(txts) = self.dns_txt(host).await { 288 for txt in txts { 289 if let Some(did_str) = txt.strip_prefix("did=") { 290 if let Ok(did) = Did::new(did_str) { 291 return Ok(did.into_static()); 292 } 293 } 294 } 295 } 296 } 297 } 298 HandleStep::HttpsWellKnown => { 299 let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 300 if let Ok(text) = self.get_text(url).await { 301 if let Ok(did) = Self::parse_atproto_did_body(&text) { 302 return Ok(did); 303 } 304 } 305 } 306 HandleStep::PdsResolveHandle => { 307 // Prefer PDS XRPC via stateless client 308 if let Ok(did) = self.resolve_handle_via_pds(handle).await { 309 return Ok(did); 310 } 311 // Public unauth fallback 312 if self.opts.public_fallback_for_handle { 313 if let Ok(mut url) = Url::parse("https://public.api.bsky.app") { 314 url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 315 if let Ok(qs) = serde_html_form::to_string( 316 &ResolveHandle::new().handle((*handle).clone()).build(), 317 ) { 318 url.set_query(Some(&qs)); 319 } else { 320 continue; 321 } 322 if let Ok((buf, status)) = self.get_json_bytes(url).await { 323 if status.is_success() { 324 if let Ok(val) = 325 serde_json::from_slice::<serde_json::Value>(&buf) 326 { 327 if let Some(did_str) = 328 val.get("did").and_then(|v| v.as_str()) 329 { 330 if let Ok(did) = Did::new_owned(did_str) { 331 return Ok(did.into_static()); 332 } 333 } 334 } 335 } 336 } 337 } 338 } 339 // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint. 340 if let PlcSource::Slingshot { base } = &self.opts.plc_source { 341 let mut url = base.clone(); 342 url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 343 if let Ok(qs) = serde_html_form::to_string( 344 &ResolveHandle::new().handle((*handle).clone()).build(), 345 ) { 346 url.set_query(Some(&qs)); 347 } else { 348 continue; 349 } 350 if let Ok((buf, status)) = self.get_json_bytes(url).await { 351 if status.is_success() { 352 if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) { 353 if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) { 354 if let Ok(did) = Did::new_owned(did_str) { 355 return Ok(did.into_static()); 356 } 357 } 358 } 359 } 360 } 361 } 362 } 363 } 364 } 365 Err(IdentityError::InvalidWellKnown) 366 } 367 368 async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 369 let s = did.as_str(); 370 for step in &self.opts.did_order { 371 match step { 372 DidStep::DidWebHttps if s.starts_with("did:web:") => { 373 let url = self.did_web_url(did)?; 374 if let Ok((buf, status)) = self.get_json_bytes(url).await { 375 return Ok(DidDocResponse { 376 buffer: buf, 377 status, 378 requested: Some(did.clone().into_static()), 379 }); 380 } 381 } 382 DidStep::PlcHttp if s.starts_with("did:plc:") => { 383 let url = match &self.opts.plc_source { 384 PlcSource::PlcDirectory { base } => { 385 // this is odd, the join screws up with the plc directory but NOT slingshot 386 Url::parse(&format!("{}{}", base, did.as_str())).expect("Invalid URL") 387 } 388 PlcSource::Slingshot { base } => base.join(did.as_str())?, 389 }; 390 println!("Fetching DID document from {}", url); 391 if let Ok((buf, status)) = self.get_json_bytes(url).await { 392 return Ok(DidDocResponse { 393 buffer: buf, 394 status, 395 requested: Some(did.clone().into_static()), 396 }); 397 } 398 } 399 DidStep::PdsResolveDid => { 400 // Try PDS XRPC for full DID doc 401 if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await { 402 let buf = serde_json::to_vec(&doc).unwrap_or_default(); 403 return Ok(DidDocResponse { 404 buffer: Bytes::from(buf), 405 status: StatusCode::OK, 406 requested: Some(did.clone().into_static()), 407 }); 408 } 409 // Fallback: if Slingshot configured, return mini-doc response (partial doc) 410 if let PlcSource::Slingshot { base } = &self.opts.plc_source { 411 let url = self.slingshot_mini_doc_url(base, did.as_str())?; 412 let (buf, status) = self.get_json_bytes(url).await?; 413 return Ok(DidDocResponse { 414 buffer: buf, 415 status, 416 requested: Some(did.clone().into_static()), 417 }); 418 } 419 } 420 _ => {} 421 } 422 } 423 Err(IdentityError::UnsupportedDidMethod(s.to_string())) 424 } 425} 426 427impl HttpClient for JacquardResolver { 428 async fn send_http( 429 &self, 430 request: http::Request<Vec<u8>>, 431 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 432 self.http.send_http(request).await 433 } 434 435 type Error = reqwest::Error; 436} 437 438/// Warnings produced during identity checks that are not fatal 439#[derive(Debug, Clone, PartialEq, Eq)] 440pub enum IdentityWarning { 441 /// The DID doc did not contain the expected handle alias under alsoKnownAs 442 HandleAliasMismatch { 443 #[allow(missing_docs)] 444 expected: Handle<'static>, 445 }, 446} 447 448impl JacquardResolver { 449 /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings. 450 /// This applies the default equality check on the document id (error with doc if mismatch). 451 pub async fn resolve_handle_and_doc( 452 &self, 453 handle: &Handle<'_>, 454 ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> { 455 let did = self.resolve_handle(handle).await?; 456 let resp = self.resolve_did_doc(&did).await?; 457 let resp_for_parse = resp.clone(); 458 let doc_borrowed = resp_for_parse.parse()?; 459 if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() { 460 return Err(IdentityError::DocIdMismatch { 461 expected: did.clone().into_static(), 462 doc: doc_borrowed.clone().into_static(), 463 }); 464 } 465 let mut warnings = Vec::new(); 466 // Check handle alias presence (soft warning) 467 let expected_alias = format!("at://{}", handle.as_str()); 468 let has_alias = doc_borrowed 469 .also_known_as 470 .as_ref() 471 .map(|v| v.iter().any(|s| s.as_ref() == expected_alias)) 472 .unwrap_or(false); 473 if !has_alias { 474 warnings.push(IdentityWarning::HandleAliasMismatch { 475 expected: handle.clone().into_static(), 476 }); 477 } 478 Ok((did, resp, warnings)) 479 } 480 481 /// Build Slingshot mini-doc URL for an identifier (handle or DID) 482 fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> { 483 let mut url = base.clone(); 484 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 485 url.set_query(Some(&format!( 486 "identifier={}", 487 urlencoding::Encoded::new(identifier) 488 ))); 489 Ok(url) 490 } 491 492 /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier 493 pub async fn fetch_mini_doc_via_slingshot_identifier( 494 &self, 495 identifier: &AtIdentifier<'_>, 496 ) -> Result<MiniDocResponse, IdentityError> { 497 let base = match &self.opts.plc_source { 498 PlcSource::Slingshot { base } => base.clone(), 499 _ => { 500 return Err(IdentityError::UnsupportedDidMethod( 501 "mini-doc requires Slingshot source".into(), 502 )); 503 } 504 }; 505 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; 506 let (buf, status) = self.get_json_bytes(url).await?; 507 Ok(MiniDocResponse { 508 buffer: buf, 509 status, 510 }) 511 } 512} 513 514/// Slingshot mini-doc JSON response wrapper 515#[derive(Clone)] 516pub struct MiniDocResponse { 517 buffer: Bytes, 518 status: StatusCode, 519} 520 521impl MiniDocResponse { 522 /// Parse borrowed MiniDoc 523 pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> { 524 if self.status.is_success() { 525 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 526 } else { 527 Err(IdentityError::HttpStatus(self.status)) 528 } 529 } 530} 531 532/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC 533pub type PublicResolver = JacquardResolver; 534 535impl Default for PublicResolver { 536 /// Build a resolver with: 537 /// - reqwest HTTP client 538 /// - Public fallbacks enabled for handle resolution 539 /// - default options (DNS enabled if compiled, public fallback for handles enabled) 540 /// 541 /// Example 542 /// ```ignore 543 /// use jacquard::identity::resolver::PublicResolver; 544 /// let resolver = PublicResolver::default(); 545 /// ``` 546 fn default() -> Self { 547 let http = reqwest::Client::new(); 548 let opts = ResolverOptions::default(); 549 let resolver = JacquardResolver::new(http, opts); 550 #[cfg(feature = "dns")] 551 let resolver = resolver.with_system_dns(); 552 resolver 553 } 554} 555 556/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and 557/// mini-doc fallbacks, unauthenticated by default. 558pub fn slingshot_resolver_default() -> PublicResolver { 559 let http = reqwest::Client::new(); 560 let mut opts = ResolverOptions::default(); 561 opts.plc_source = PlcSource::slingshot_default(); 562 let resolver = JacquardResolver::new(http, opts); 563 #[cfg(feature = "dns")] 564 let resolver = resolver.with_system_dns(); 565 resolver 566} 567 568#[cfg(test)] 569mod tests { 570 use super::*; 571 572 #[test] 573 fn did_web_urls() { 574 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default()); 575 assert_eq!( 576 r.test_did_web_url_raw("did:web:example.com"), 577 "https://example.com/.well-known/did.json" 578 ); 579 assert_eq!( 580 r.test_did_web_url_raw("did:web:example.com:user:alice"), 581 "https://example.com/user/alice/did.json" 582 ); 583 } 584 585 #[test] 586 fn slingshot_mini_doc_url_build() { 587 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default()); 588 let base = Url::parse("https://slingshot.microcosm.blue").unwrap(); 589 let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap(); 590 assert_eq!( 591 url.as_str(), 592 "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com" 593 ); 594 } 595 596 #[test] 597 fn slingshot_mini_doc_parse_success() { 598 let buf = Bytes::from_static( 599 br#"{ 600 "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid", 601 "handle": "bad-example.com", 602 "pds": "https://porcini.us-east.host.bsky.network", 603 "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j" 604}"#, 605 ); 606 let resp = MiniDocResponse { 607 buffer: buf, 608 status: StatusCode::OK, 609 }; 610 let doc = resp.parse().expect("parse mini-doc"); 611 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); 612 assert_eq!(doc.handle.as_str(), "bad-example.com"); 613 assert_eq!( 614 doc.pds.as_ref(), 615 "https://porcini.us-east.host.bsky.network" 616 ); 617 assert!(doc.signing_key.as_ref().starts_with('z')); 618 } 619 620 #[test] 621 fn slingshot_mini_doc_parse_error_status() { 622 let buf = Bytes::from_static( 623 br#"{ 624 "error": "RecordNotFound", 625 "message": "This record was deleted" 626}"#, 627 ); 628 let resp = MiniDocResponse { 629 buffer: buf, 630 status: StatusCode::BAD_REQUEST, 631 }; 632 match resp.parse() { 633 Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST), 634 other => panic!("unexpected: {:?}", other), 635 } 636 } 637}