atproto_identity/
resolve.rs

1//! AT Protocol identity resolution for handles and DIDs.
2//!
3//! Resolves AT Protocol identities via DNS TXT records and HTTPS well-known endpoints,
4//! with automatic input detection for handles, did:plc, and did:web identifiers.
5//! - **Validation**: Ensures DNS and HTTP resolution methods agree on the resolved DID
6//! - **Custom DNS**: Supports custom DNS nameservers for resolution
7//!
8//! ## Resolution Flow
9//!
10//! 1. Parse input to determine identifier type (handle vs DID)
11//! 2. For handles: perform parallel DNS and HTTP resolution
12//! 3. Validate that both methods return the same DID
13//! 4. For DIDs: return the identifier directly
14
15use anyhow::Result;
16#[cfg(feature = "hickory-dns")]
17use hickory_resolver::{
18    Resolver, TokioResolver,
19    config::{NameServerConfigGroup, ResolverConfig},
20    name_server::TokioConnectionProvider,
21};
22use reqwest::Client;
23use std::collections::HashSet;
24use std::ops::Deref;
25use std::sync::Arc;
26use std::time::Duration;
27use tracing::{Instrument, instrument};
28
29use crate::errors::ResolveError;
30use crate::model::Document;
31use crate::plc::query as plc_query;
32use crate::validation::{is_valid_did_method_plc, is_valid_handle};
33use crate::web::query as web_query;
34
35/// Trait for AT Protocol identity resolution.
36///
37/// Implementations must be thread-safe (Send + Sync) and usable in async environments.
38/// This trait provides the core functionality for resolving AT Protocol subjects
39/// (handles or DIDs) to their corresponding DID documents.
40#[async_trait::async_trait]
41pub trait IdentityResolver: Send + Sync {
42    /// Resolves an AT Protocol subject to its DID document.
43    ///
44    /// Takes a handle or DID, resolves it to a canonical DID, then retrieves
45    /// the corresponding DID document from the appropriate source (PLC directory or web).
46    ///
47    /// # Arguments
48    /// * `subject` - The AT Protocol handle or DID to resolve
49    ///
50    /// # Returns
51    /// * `Ok(Document)` - The resolved DID document
52    /// * `Err(anyhow::Error)` - Resolution error with detailed context
53    async fn resolve(&self, subject: &str) -> Result<Document>;
54}
55
56/// Trait for DNS resolution operations.
57/// Provides async DNS TXT record lookups for handle resolution.
58#[async_trait::async_trait]
59pub trait DnsResolver: Send + Sync {
60    /// Resolves TXT records for a given domain name.
61    /// Returns a vector of strings representing the TXT record values.
62    async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>;
63}
64
65/// Hickory DNS implementation of the DnsResolver trait.
66/// Wraps hickory_resolver::TokioResolver for TXT record resolution.
67#[cfg(feature = "hickory-dns")]
68#[derive(Clone)]
69pub struct HickoryDnsResolver {
70    resolver: TokioResolver,
71}
72
73#[cfg(feature = "hickory-dns")]
74impl HickoryDnsResolver {
75    /// Creates a new HickoryDnsResolver with the given TokioResolver.
76    pub fn new(resolver: TokioResolver) -> Self {
77        Self { resolver }
78    }
79
80    /// Creates a DNS resolver with custom or system nameservers.
81    /// Uses custom nameservers if provided, otherwise system defaults.
82    pub fn create_resolver(nameservers: &[std::net::IpAddr]) -> Self {
83        // Initialize the DNS resolver with custom nameservers if configured
84        let tokio_resolver = if !nameservers.is_empty() {
85            tracing::debug!("Using custom DNS nameservers: {:?}", nameservers);
86            let nameserver_group = NameServerConfigGroup::from_ips_clear(nameservers, 53, true);
87            let resolver_config = ResolverConfig::from_parts(None, vec![], nameserver_group);
88            Resolver::builder_with_config(resolver_config, TokioConnectionProvider::default())
89                .build()
90        } else {
91            tracing::debug!("Using system default DNS nameservers");
92            Resolver::builder_tokio().unwrap().build()
93        };
94        Self::new(tokio_resolver)
95    }
96}
97
98#[cfg(feature = "hickory-dns")]
99#[async_trait::async_trait]
100impl DnsResolver for HickoryDnsResolver {
101    async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError> {
102        let lookup = self
103            .resolver
104            .txt_lookup(domain)
105            .instrument(tracing::info_span!("txt_lookup"))
106            .await
107            .map_err(|error| ResolveError::DNSResolutionFailed { error })?;
108
109        Ok(lookup.iter().map(|record| record.to_string()).collect())
110    }
111}
112
113/// Type of input identifier for resolution.
114/// Distinguishes between handles and different DID methods.
115pub enum InputType {
116    /// AT Protocol handle (e.g., "alice.bsky.social").
117    Handle(String),
118    /// PLC DID identifier (e.g., "did:plc:abc123").
119    Plc(String),
120    /// Web DID identifier (e.g., "did:web:example.com").
121    Web(String),
122}
123
124/// Resolves a handle to DID using DNS TXT records.
125/// Looks up _atproto.{handle} TXT record for DID value.
126#[instrument(skip(dns_resolver), err)]
127pub async fn resolve_handle_dns<R: DnsResolver + ?Sized>(
128    dns_resolver: &R,
129    lookup_dns: &str,
130) -> Result<String, ResolveError> {
131    let txt_records = dns_resolver
132        .resolve_txt(&format!("_atproto.{}", lookup_dns))
133        .await?;
134
135    let dids = txt_records
136        .iter()
137        .filter_map(|record| record.strip_prefix("did=").map(|did| did.to_string()))
138        .collect::<HashSet<String>>();
139
140    if dids.len() > 1 {
141        return Err(ResolveError::MultipleDIDsFound);
142    }
143
144    dids.iter().next().cloned().ok_or(ResolveError::NoDIDsFound)
145}
146
147/// Resolves a handle to DID using HTTPS well-known endpoint.
148/// Fetches DID from https://{handle}/.well-known/atproto-did
149#[instrument(skip(http_client), err)]
150pub async fn resolve_handle_http(
151    http_client: &reqwest::Client,
152    handle: &str,
153) -> Result<String, ResolveError> {
154    let lookup_url = format!("https://{}/.well-known/atproto-did", handle);
155
156    http_client
157        .get(lookup_url.clone())
158        .timeout(Duration::from_secs(10))
159        .send()
160        .instrument(tracing::info_span!("http_client_get"))
161        .await
162        .map_err(|error| ResolveError::HTTPResolutionFailed { error })?
163        .text()
164        .instrument(tracing::info_span!("response_text"))
165        .await
166        .map_err(|error| ResolveError::HTTPResolutionFailed { error })
167        .and_then(|body| {
168            if body.starts_with("did:") {
169                Ok(body.trim().to_string())
170            } else {
171                Err(ResolveError::InvalidHTTPResolutionResponse)
172            }
173        })
174}
175
176/// Parses input string into appropriate identifier type.
177/// Handles prefixes like "at://", "@", and DID formats.
178pub fn parse_input(input: &str) -> Result<InputType, ResolveError> {
179    let trimmed = {
180        if let Some(value) = input.trim().strip_prefix("at://") {
181            value.trim()
182        } else if let Some(value) = input.trim().strip_prefix('@') {
183            value.trim()
184        } else {
185            input.trim()
186        }
187    };
188    if trimmed.is_empty() {
189        return Err(ResolveError::InvalidInput);
190    }
191    if trimmed.starts_with("did:web:") {
192        Ok(InputType::Web(trimmed.to_string()))
193    } else if trimmed.starts_with("did:plc:") && is_valid_did_method_plc(trimmed) {
194        Ok(InputType::Plc(trimmed.to_string()))
195    } else {
196        is_valid_handle(trimmed)
197            .map(InputType::Handle)
198            .ok_or(ResolveError::InvalidInput)
199    }
200}
201
202/// Resolves a handle to DID using both DNS and HTTP methods.
203/// Returns DID if both methods agree, or error if conflicting.
204#[instrument(skip(http_client, dns_resolver), err)]
205pub async fn resolve_handle<R: DnsResolver + ?Sized>(
206    http_client: &reqwest::Client,
207    dns_resolver: &R,
208    handle: &str,
209) -> Result<String, ResolveError> {
210    let trimmed = {
211        if let Some(value) = handle.trim().strip_prefix("at://") {
212            value
213        } else if let Some(value) = handle.trim().strip_prefix('@') {
214            value
215        } else {
216            handle.trim()
217        }
218    };
219
220    let (dns_lookup, http_lookup) = tokio::join!(
221        resolve_handle_dns(dns_resolver, trimmed),
222        resolve_handle_http(http_client, trimmed),
223    );
224
225    let results = vec![dns_lookup, http_lookup]
226        .into_iter()
227        .filter_map(|result| result.ok())
228        .collect::<Vec<String>>();
229    if results.is_empty() {
230        return Err(ResolveError::NoDIDsFound);
231    }
232
233    let first = results[0].clone();
234    if results.iter().all(|result| result == &first) {
235        return Ok(first);
236    }
237    Err(ResolveError::ConflictingDIDsFound)
238}
239
240/// Resolves any subject (handle or DID) to a canonical DID.
241/// Handles all supported identifier formats automatically.
242#[instrument(skip(http_client, dns_resolver), err)]
243pub async fn resolve_subject<R: DnsResolver + ?Sized>(
244    http_client: &reqwest::Client,
245    dns_resolver: &R,
246    subject: &str,
247) -> Result<String, ResolveError> {
248    match parse_input(subject)? {
249        InputType::Handle(handle) => resolve_handle(http_client, dns_resolver, &handle).await,
250        InputType::Plc(did) | InputType::Web(did) => Ok(did),
251    }
252}
253
254/// Core identity resolution components for AT Protocol subjects.
255///
256/// Contains the networking and configuration components needed to resolve
257/// handles and DIDs to their corresponding DID documents.
258pub struct InnerIdentityResolver {
259    /// DNS resolver for handle-to-DID resolution via TXT records.
260    pub dns_resolver: Arc<dyn DnsResolver>,
261    /// HTTP client for DID document retrieval and well-known endpoint queries.
262    pub http_client: Client,
263    /// Hostname of the PLC directory server for `did:plc` resolution.
264    pub plc_hostname: String,
265}
266
267/// Shared identity resolver for AT Protocol subjects.
268///
269/// Wraps `InnerIdentityResolver` in an Arc for shared access across threads,
270/// enabling resolution of AT Protocol handles and DIDs to DID documents.
271#[derive(Clone)]
272pub struct SharedIdentityResolver(pub Arc<InnerIdentityResolver>);
273
274impl Deref for SharedIdentityResolver {
275    type Target = InnerIdentityResolver;
276
277    fn deref(&self) -> &Self::Target {
278        &self.0
279    }
280}
281
282#[async_trait::async_trait]
283impl IdentityResolver for SharedIdentityResolver {
284    async fn resolve(&self, subject: &str) -> Result<Document> {
285        self.0.resolve(subject).await
286    }
287}
288
289#[async_trait::async_trait]
290impl IdentityResolver for InnerIdentityResolver {
291    async fn resolve(&self, subject: &str) -> Result<Document> {
292        let resolved_did = resolve_subject(&self.http_client, &*self.dns_resolver, subject).await?;
293
294        match parse_input(&resolved_did) {
295            Ok(InputType::Plc(did)) => plc_query(&self.http_client, &self.plc_hostname, &did)
296                .await
297                .map_err(Into::into),
298            Ok(InputType::Web(did)) => web_query(&self.http_client, &did).await.map_err(Into::into),
299            Ok(InputType::Handle(_)) => Err(ResolveError::SubjectResolvedToHandle.into()),
300            Err(err) => Err(err.into()),
301        }
302    }
303}
304
305impl InnerIdentityResolver {
306    /// Resolves an AT Protocol subject to its DID document.
307    ///
308    /// Takes a handle or DID, resolves it to a canonical DID, then retrieves
309    /// the corresponding DID document from the appropriate source (PLC directory or web).
310    pub async fn resolve(&self, subject: &str) -> Result<Document> {
311        <Self as IdentityResolver>::resolve(self, subject).await
312    }
313}