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}