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}