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