A better Rust ATProto crate
at main 18 kB view raw
1//! Identity resolution: handle → DID and DID → document, with smart fallbacks. 2//! 3//! Fallback order (default): 4//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC 5//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured). 6//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured), 7//! then Slingshot mini‑doc (partial) if configured. 8//! 9//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 10//! and optionally validate the document `id` against the requested DID. 11 12use std::collections::BTreeMap; 13use std::marker::Sync; 14use std::str::FromStr; 15 16use bon::Builder; 17use bytes::Bytes; 18use http::StatusCode; 19use jacquard_common::error::TransportError; 20use jacquard_common::types::did::Did; 21use jacquard_common::types::did_doc::{DidDocument, Service}; 22use jacquard_common::types::ident::AtIdentifier; 23use jacquard_common::types::string::{AtprotoStr, Handle}; 24use jacquard_common::types::uri::Uri; 25use jacquard_common::types::value::{AtDataError, Data}; 26use jacquard_common::{CowStr, IntoStatic}; 27use miette::Diagnostic; 28use thiserror::Error; 29use url::Url; 30 31/// Errors that can occur during identity resolution. 32/// 33/// Note: when validating a fetched DID document against a requested DID, a 34/// `DocIdMismatch` error is returned that includes the owned document so callers 35/// can inspect it and decide how to proceed. 36#[derive(Debug, Error, Diagnostic)] 37#[allow(missing_docs)] 38pub enum IdentityError { 39 #[error("unsupported DID method: {0}")] 40 #[diagnostic( 41 code(jacquard_identity::unsupported_did_method), 42 help("supported DID methods: did:web, did:plc") 43 )] 44 UnsupportedDidMethod(String), 45 #[error("invalid well-known atproto-did content")] 46 #[diagnostic( 47 code(jacquard_identity::invalid_well_known), 48 help("expected first non-empty line to be a DID") 49 )] 50 InvalidWellKnown, 51 #[error("missing PDS endpoint in DID document")] 52 #[diagnostic(code(jacquard_identity::missing_pds_endpoint))] 53 MissingPdsEndpoint, 54 #[error("HTTP error: {0}")] 55 #[diagnostic( 56 code(jacquard_identity::http), 57 help("check network connectivity and TLS configuration") 58 )] 59 Http(#[from] TransportError), 60 #[error("HTTP status {0}")] 61 #[diagnostic( 62 code(jacquard_identity::http_status), 63 help("verify well-known paths or PDS XRPC endpoints") 64 )] 65 HttpStatus(StatusCode), 66 #[error("XRPC error: {0}")] 67 #[diagnostic( 68 code(jacquard_identity::xrpc), 69 help("enable PDS fallback or public resolver if needed") 70 )] 71 Xrpc(String), 72 #[error("URL parse error: {0}")] 73 #[diagnostic(code(jacquard_identity::url))] 74 Url(#[from] url::ParseError), 75 #[error("DNS error: {0}")] 76 #[cfg(feature = "dns")] 77 #[diagnostic(code(jacquard_identity::dns))] 78 Dns(#[from] hickory_resolver::error::ResolveError), 79 #[error("serialize/deserialize error: {0}")] 80 #[diagnostic(code(jacquard_identity::serde))] 81 Serde(#[from] serde_json::Error), 82 #[error("invalid DID document: {0}")] 83 #[diagnostic( 84 code(jacquard_identity::invalid_doc), 85 help("validate keys and services; ensure AtprotoPersonalDataServer service exists") 86 )] 87 InvalidDoc(String), 88 #[error(transparent)] 89 #[diagnostic(code(jacquard_identity::data))] 90 Data(#[from] AtDataError), 91 /// DID document id did not match requested DID; includes the fetched document 92 #[error("DID doc id mismatch")] 93 #[diagnostic( 94 code(jacquard_identity::doc_id_mismatch), 95 help("document id differs from requested DID; do not trust this document") 96 )] 97 DocIdMismatch { 98 expected: Did<'static>, 99 doc: DidDocument<'static>, 100 }, 101} 102 103/// Source to fetch PLC (did:plc) documents from. 104/// 105/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`). 106/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as 107/// `com.atproto.identity.resolveHandle` and a "mini-doc" 108/// endpoint (`com.bad-example.identity.resolveMiniDoc`). 109#[derive(Debug, Clone, PartialEq, Eq)] 110pub enum PlcSource { 111 /// Use the public PLC directory 112 PlcDirectory { 113 /// Base URL for the PLC directory 114 base: Url, 115 }, 116 /// Use the slingshot mini-docs service 117 Slingshot { 118 /// Base URL for the Slingshot service 119 base: Url, 120 }, 121} 122 123impl Default for PlcSource { 124 fn default() -> Self { 125 Self::PlcDirectory { 126 base: Url::parse("https://plc.directory/").expect("valid url"), 127 } 128 } 129} 130 131impl PlcSource { 132 /// Default Slingshot source (`https://slingshot.microcosm.blue`) 133 pub fn slingshot_default() -> Self { 134 PlcSource::Slingshot { 135 base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"), 136 } 137 } 138} 139 140/// DID Document fetch response for borrowed/owned parsing. 141/// 142/// Carries the raw response bytes and the HTTP status, plus the requested DID 143/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer 144/// or `parse_validated()` to also enforce that the doc `id` matches the 145/// requested DID (returns a `DocIdMismatch` containing the fetched doc on 146/// mismatch). Use `into_owned()` to parse into an owned document. 147#[derive(Clone)] 148pub struct DidDocResponse { 149 #[allow(missing_docs)] 150 pub buffer: Bytes, 151 #[allow(missing_docs)] 152 pub status: StatusCode, 153 /// Optional DID we intended to resolve; used for validation helpers 154 pub requested: Option<Did<'static>>, 155} 156 157impl DidDocResponse { 158 /// Parse as borrowed DidDocument<'_> 159 pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 160 if self.status.is_success() { 161 if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) { 162 Ok(doc) 163 } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) { 164 Ok(DidDocument { 165 id: mini_doc.did, 166 also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 167 verification_method: None, 168 service: Some(vec![Service { 169 id: CowStr::new_static("#atproto_pds"), 170 r#type: CowStr::new_static("AtprotoPersonalDataServer"), 171 service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 172 Url::from_str(&mini_doc.pds).unwrap(), 173 )))), 174 extra_data: BTreeMap::new(), 175 }]), 176 extra_data: BTreeMap::new(), 177 }) 178 } else { 179 Err(IdentityError::MissingPdsEndpoint) 180 } 181 } else { 182 Err(IdentityError::HttpStatus(self.status)) 183 } 184 } 185 186 /// Parse and validate that the DID in the document matches the requested DID if present. 187 /// 188 /// On mismatch, returns an error that contains the owned document for inspection. 189 pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> { 190 let doc = self.parse()?; 191 if let Some(expected) = &self.requested { 192 if doc.id.as_str() != expected.as_str() { 193 return Err(IdentityError::DocIdMismatch { 194 expected: expected.clone(), 195 doc: doc.clone().into_static(), 196 }); 197 } 198 } 199 Ok(doc) 200 } 201 202 /// Parse as owned DidDocument<'static> 203 pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> { 204 if self.status.is_success() { 205 if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) { 206 Ok(doc.into_static()) 207 } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) { 208 Ok(DidDocument { 209 id: mini_doc.did, 210 also_known_as: Some(vec![CowStr::from(mini_doc.handle)]), 211 verification_method: None, 212 service: Some(vec![Service { 213 id: CowStr::new_static("#atproto_pds"), 214 r#type: CowStr::new_static("AtprotoPersonalDataServer"), 215 service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https( 216 Url::from_str(&mini_doc.pds).unwrap(), 217 )))), 218 extra_data: BTreeMap::new(), 219 }]), 220 extra_data: BTreeMap::new(), 221 } 222 .into_static()) 223 } else { 224 Err(IdentityError::MissingPdsEndpoint) 225 } 226 } else { 227 Err(IdentityError::HttpStatus(self.status)) 228 } 229 } 230} 231 232/// Slingshot mini-doc data (subset of DID doc info) 233#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 234#[serde(rename_all = "camelCase")] 235#[allow(missing_docs)] 236pub struct MiniDoc<'a> { 237 #[serde(borrow)] 238 pub did: Did<'a>, 239 #[serde(borrow)] 240 pub handle: Handle<'a>, 241 #[serde(borrow)] 242 pub pds: CowStr<'a>, 243 #[serde(borrow, rename = "signingKey", alias = "signing_key")] 244 pub signing_key: CowStr<'a>, 245} 246 247/// Handle → DID fallback step. 248#[derive(Debug, Clone, Copy, PartialEq, Eq)] 249pub enum HandleStep { 250 /// DNS TXT _atproto.\<handle\> 251 DnsTxt, 252 /// HTTPS GET https://\<handle\>/.well-known/atproto-did 253 HttpsWellKnown, 254 /// XRPC com.atproto.identity.resolveHandle against a provided PDS base 255 PdsResolveHandle, 256} 257 258/// DID → Doc fallback step. 259#[derive(Debug, Clone, Copy, PartialEq, Eq)] 260pub enum DidStep { 261 /// For did:web: fetch from the well-known location 262 DidWebHttps, 263 /// For did:plc: fetch from PLC source 264 PlcHttp, 265 /// If a PDS base is known, ask it for the DID doc 266 PdsResolveDid, 267} 268 269/// Configurable resolver options. 270/// 271/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot). 272/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless 273/// XRPC over reqwest; authentication can be layered as needed). 274/// - `handle_order`/`did_order`: ordered strategies for resolution. 275/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID, 276/// returning `DocIdMismatch` with the fetched document on mismatch. 277/// - `public_fallback_for_handle`: if true (default), attempt 278/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback. 279/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC 280/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured. 281#[derive(Debug, Clone, Builder)] 282#[builder(start_fn = new)] 283pub struct ResolverOptions { 284 /// PLC data source (directory or slingshot) 285 pub plc_source: PlcSource, 286 /// Optional PDS base to use for fallbacks 287 pub pds_fallback: Option<Url>, 288 /// Order of attempts for handle → DID resolution 289 pub handle_order: Vec<HandleStep>, 290 /// Order of attempts for DID → Doc resolution 291 pub did_order: Vec<DidStep>, 292 /// Validate that fetched DID document id matches the requested DID 293 pub validate_doc_id: bool, 294 /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 295 pub public_fallback_for_handle: bool, 296} 297 298impl Default for ResolverOptions { 299 fn default() -> Self { 300 // By default, prefer DNS then HTTPS for handles, then PDS fallback 301 // For DID documents, prefer method-native sources, then PDS fallback 302 Self::new() 303 .plc_source(PlcSource::default()) 304 .handle_order(vec![ 305 HandleStep::DnsTxt, 306 HandleStep::HttpsWellKnown, 307 HandleStep::PdsResolveHandle, 308 ]) 309 .did_order(vec![ 310 DidStep::DidWebHttps, 311 DidStep::PlcHttp, 312 DidStep::PdsResolveDid, 313 ]) 314 .validate_doc_id(true) 315 .public_fallback_for_handle(true) 316 .build() 317 } 318} 319 320/// Trait for identity resolution, for pluggable implementations. 321/// 322/// The provided `DefaultResolver` supports: 323/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature 324/// - HTTPS well-known for handles and `did:web` 325/// - PLC directory or Slingshot for `did:plc` 326/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 327/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 328 329pub trait IdentityResolver { 330 /// Access options for validation decisions in default methods 331 fn options(&self) -> &ResolverOptions; 332 333 /// Resolve handle 334 fn resolve_handle( 335 &self, 336 handle: &Handle<'_>, 337 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send 338 where 339 Self: Sync; 340 341 /// Resolve DID document 342 fn resolve_did_doc( 343 &self, 344 did: &Did<'_>, 345 ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send 346 where 347 Self: Sync; 348 349 /// Resolve DID doc from an identifier 350 fn resolve_ident( 351 &self, 352 actor: &AtIdentifier<'_>, 353 ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send 354 where 355 Self: Sync, 356 { 357 async move { 358 match actor { 359 AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, 360 AtIdentifier::Handle(handle) => { 361 let did = self.resolve_handle(&handle).await?; 362 self.resolve_did_doc(&did).await 363 } 364 } 365 } 366 } 367 368 /// Resolve DID doc from an identifier 369 fn resolve_ident_owned( 370 &self, 371 actor: &AtIdentifier<'_>, 372 ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send 373 where 374 Self: Sync, 375 { 376 async move { 377 match actor { 378 AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, 379 AtIdentifier::Handle(handle) => { 380 let did = self.resolve_handle(&handle).await?; 381 self.resolve_did_doc_owned(&did).await 382 } 383 } 384 } 385 } 386 387 /// Resolve the DID document and return an owned version 388 fn resolve_did_doc_owned( 389 &self, 390 did: &Did<'_>, 391 ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send 392 where 393 Self: Sync, 394 { 395 async { self.resolve_did_doc(did).await?.into_owned() } 396 } 397 /// Return the PDS url for a DID 398 fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> + Send 399 where 400 Self: Sync, 401 { 402 async { 403 let resp = self.resolve_did_doc(did).await?; 404 let doc = resp.parse()?; 405 // Default-on doc id equality check 406 if self.options().validate_doc_id { 407 if doc.id.as_str() != did.as_str() { 408 return Err(IdentityError::DocIdMismatch { 409 expected: did.clone().into_static(), 410 doc: doc.clone().into_static(), 411 }); 412 } 413 } 414 doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 415 } 416 } 417 /// Return the DIS and PDS url for a handle 418 fn pds_for_handle( 419 &self, 420 handle: &Handle<'_>, 421 ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> + Send 422 where 423 Self: Sync, 424 { 425 async { 426 let did = self.resolve_handle(handle).await?; 427 let pds = self.pds_for_did(&did).await?; 428 Ok((did, pds)) 429 } 430 } 431} 432 433impl<T: IdentityResolver + Sync> IdentityResolver for std::sync::Arc<T> { 434 fn options(&self) -> &ResolverOptions { 435 self.as_ref().options() 436 } 437 438 /// Resolve handle 439 async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 440 self.as_ref().resolve_handle(handle).await 441 } 442 443 /// Resolve DID document 444 async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 445 self.as_ref().resolve_did_doc(did).await 446 } 447} 448 449#[cfg(test)] 450mod tests { 451 use super::*; 452 453 #[test] 454 fn parse_validated_ok() { 455 let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#); 456 let requested = Did::new_owned("did:plc:alice").unwrap(); 457 let resp = DidDocResponse { 458 buffer: buf, 459 status: StatusCode::OK, 460 requested: Some(requested), 461 }; 462 let _doc = resp.parse_validated().expect("valid"); 463 } 464 465 #[test] 466 fn parse_validated_mismatch() { 467 let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#); 468 let requested = Did::new_owned("did:plc:alice").unwrap(); 469 let resp = DidDocResponse { 470 buffer: buf, 471 status: StatusCode::OK, 472 requested: Some(requested), 473 }; 474 match resp.parse_validated() { 475 Err(IdentityError::DocIdMismatch { expected, doc }) => { 476 assert_eq!(expected.as_str(), "did:plc:alice"); 477 assert_eq!(doc.id.as_str(), "did:plc:bob"); 478 } 479 other => panic!("unexpected result: {:?}", other), 480 } 481 } 482}