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