this repo has no description
1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::RwLock;
7use tracing::{debug, error, info, warn};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DidDocument {
11 pub id: String,
12 #[serde(default)]
13 pub service: Vec<DidService>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct DidService {
19 pub id: String,
20 #[serde(rename = "type")]
21 pub service_type: String,
22 pub service_endpoint: String,
23}
24
25#[derive(Clone)]
26struct CachedDid {
27 url: String,
28 did: String,
29 resolved_at: Instant,
30}
31
32#[derive(Clone)]
33struct CachedDidDocument {
34 document: serde_json::Value,
35 resolved_at: Instant,
36}
37
38#[derive(Debug, Clone)]
39pub struct ResolvedService {
40 pub url: String,
41 pub did: String,
42}
43
44#[derive(Clone)]
45pub struct DidResolver {
46 did_cache: Arc<RwLock<HashMap<String, CachedDid>>>,
47 did_doc_cache: Arc<RwLock<HashMap<String, CachedDidDocument>>>,
48 client: Client,
49 cache_ttl: Duration,
50 plc_directory_url: String,
51}
52
53impl DidResolver {
54 pub fn new() -> Self {
55 let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS")
56 .ok()
57 .and_then(|v| v.parse().ok())
58 .unwrap_or(300);
59
60 let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
61 .unwrap_or_else(|_| "https://plc.directory".to_string());
62
63 let client = Client::builder()
64 .timeout(Duration::from_secs(10))
65 .connect_timeout(Duration::from_secs(5))
66 .pool_max_idle_per_host(10)
67 .build()
68 .unwrap_or_else(|_| Client::new());
69
70 info!("DID resolver initialized");
71
72 Self {
73 did_cache: Arc::new(RwLock::new(HashMap::new())),
74 did_doc_cache: Arc::new(RwLock::new(HashMap::new())),
75 client,
76 cache_ttl: Duration::from_secs(cache_ttl_secs),
77 plc_directory_url,
78 }
79 }
80
81 fn build_did_web_url(did: &str) -> Result<String, String> {
82 let host = did
83 .strip_prefix("did:web:")
84 .ok_or("Invalid did:web format")?;
85
86 let (host, path) = if host.contains(':') {
87 let decoded = host.replace("%3A", ":");
88 let parts: Vec<&str> = decoded.splitn(2, '/').collect();
89 if parts.len() > 1 {
90 (parts[0].to_string(), format!("/{}", parts[1]))
91 } else {
92 (decoded, String::new())
93 }
94 } else {
95 let parts: Vec<&str> = host.splitn(2, ':').collect();
96 if parts.len() > 1 && parts[1].contains('/') {
97 let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect();
98 if path_parts.len() > 1 {
99 (
100 format!("{}:{}", parts[0], path_parts[0]),
101 format!("/{}", path_parts[1]),
102 )
103 } else {
104 (host.to_string(), String::new())
105 }
106 } else {
107 (host.to_string(), String::new())
108 }
109 };
110
111 let scheme =
112 if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':')
113 {
114 "http"
115 } else {
116 "https"
117 };
118
119 let url = if path.is_empty() {
120 format!("{}://{}/.well-known/did.json", scheme, host)
121 } else {
122 format!("{}://{}{}/did.json", scheme, host, path)
123 };
124
125 Ok(url)
126 }
127
128 pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> {
129 {
130 let cache = self.did_cache.read().await;
131 if let Some(cached) = cache.get(did)
132 && cached.resolved_at.elapsed() < self.cache_ttl
133 {
134 return Some(ResolvedService {
135 url: cached.url.clone(),
136 did: cached.did.clone(),
137 });
138 }
139 }
140
141 let resolved = self.resolve_did_internal(did).await?;
142
143 {
144 let mut cache = self.did_cache.write().await;
145 cache.insert(
146 did.to_string(),
147 CachedDid {
148 url: resolved.url.clone(),
149 did: resolved.did.clone(),
150 resolved_at: Instant::now(),
151 },
152 );
153 }
154
155 Some(resolved)
156 }
157
158 pub async fn refresh_did(&self, did: &str) -> Option<ResolvedService> {
159 {
160 let mut cache = self.did_cache.write().await;
161 cache.remove(did);
162 }
163 self.resolve_did(did).await
164 }
165
166 async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedService> {
167 let did_doc = if did.starts_with("did:web:") {
168 self.resolve_did_web(did).await
169 } else if did.starts_with("did:plc:") {
170 self.resolve_did_plc(did).await
171 } else {
172 warn!("Unsupported DID method: {}", did);
173 return None;
174 };
175
176 let doc = match did_doc {
177 Ok(doc) => doc,
178 Err(e) => {
179 error!("Failed to resolve DID {}: {}", did, e);
180 return None;
181 }
182 };
183
184 self.extract_service_endpoint(&doc)
185 }
186
187 async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> {
188 let url = Self::build_did_web_url(did)?;
189
190 debug!("Resolving did:web {} via {}", did, url);
191
192 let resp = self
193 .client
194 .get(&url)
195 .send()
196 .await
197 .map_err(|e| format!("HTTP request failed: {}", e))?;
198
199 if !resp.status().is_success() {
200 return Err(format!("HTTP {}", resp.status()));
201 }
202
203 resp.json::<DidDocument>()
204 .await
205 .map_err(|e| format!("Failed to parse DID document: {}", e))
206 }
207
208 async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> {
209 let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
210
211 debug!("Resolving did:plc {} via {}", did, url);
212
213 let resp = self
214 .client
215 .get(&url)
216 .send()
217 .await
218 .map_err(|e| format!("HTTP request failed: {}", e))?;
219
220 if resp.status() == reqwest::StatusCode::NOT_FOUND {
221 return Err("DID not found".to_string());
222 }
223
224 if !resp.status().is_success() {
225 return Err(format!("HTTP {}", resp.status()));
226 }
227
228 resp.json::<DidDocument>()
229 .await
230 .map_err(|e| format!("Failed to parse DID document: {}", e))
231 }
232
233 fn extract_service_endpoint(&self, doc: &DidDocument) -> Option<ResolvedService> {
234 for service in &doc.service {
235 if service.service_type == "AtprotoAppView"
236 || service.id.contains("atproto_appview")
237 || service.id.ends_with("#bsky_appview")
238 {
239 return Some(ResolvedService {
240 url: service.service_endpoint.clone(),
241 did: doc.id.clone(),
242 });
243 }
244 }
245
246 for service in &doc.service {
247 if service.service_type.contains("AppView") || service.id.contains("appview") {
248 return Some(ResolvedService {
249 url: service.service_endpoint.clone(),
250 did: doc.id.clone(),
251 });
252 }
253 }
254
255 if let Some(service) = doc.service.first()
256 && service.service_endpoint.starts_with("http")
257 {
258 warn!(
259 "No explicit AppView service found for {}, using first service: {}",
260 doc.id, service.service_endpoint
261 );
262 return Some(ResolvedService {
263 url: service.service_endpoint.clone(),
264 did: doc.id.clone(),
265 });
266 }
267
268 if doc.id.starts_with("did:web:") {
269 let host = doc.id.strip_prefix("did:web:")?;
270 let decoded_host = host.replace("%3A", ":");
271 let base_host = decoded_host.split('/').next()?;
272 let scheme = if base_host.starts_with("localhost")
273 || base_host.starts_with("127.0.0.1")
274 || base_host.contains(':')
275 {
276 "http"
277 } else {
278 "https"
279 };
280 warn!(
281 "No service found for {}, deriving URL from DID: {}://{}",
282 doc.id, scheme, base_host
283 );
284 return Some(ResolvedService {
285 url: format!("{}://{}", scheme, base_host),
286 did: doc.id.clone(),
287 });
288 }
289
290 None
291 }
292
293 pub async fn resolve_did_document(&self, did: &str) -> Option<serde_json::Value> {
294 {
295 let cache = self.did_doc_cache.read().await;
296 if let Some(cached) = cache.get(did)
297 && cached.resolved_at.elapsed() < self.cache_ttl
298 {
299 return Some(cached.document.clone());
300 }
301 }
302
303 let result = if did.starts_with("did:web:") {
304 self.fetch_did_document_web(did).await
305 } else if did.starts_with("did:plc:") {
306 self.fetch_did_document_plc(did).await
307 } else {
308 warn!("Unsupported DID method for document resolution: {}", did);
309 return None;
310 };
311
312 match result {
313 Ok(doc) => {
314 let mut cache = self.did_doc_cache.write().await;
315 cache.insert(
316 did.to_string(),
317 CachedDidDocument {
318 document: doc.clone(),
319 resolved_at: Instant::now(),
320 },
321 );
322 Some(doc)
323 }
324 Err(e) => {
325 warn!("Failed to resolve DID document for {}: {}", did, e);
326 None
327 }
328 }
329 }
330
331 async fn fetch_did_document_web(&self, did: &str) -> Result<serde_json::Value, String> {
332 let url = Self::build_did_web_url(did)?;
333
334 let resp = self
335 .client
336 .get(&url)
337 .send()
338 .await
339 .map_err(|e| format!("HTTP request failed: {}", e))?;
340
341 if !resp.status().is_success() {
342 return Err(format!("HTTP {}", resp.status()));
343 }
344
345 resp.json::<serde_json::Value>()
346 .await
347 .map_err(|e| format!("Failed to parse DID document: {}", e))
348 }
349
350 async fn fetch_did_document_plc(&self, did: &str) -> Result<serde_json::Value, String> {
351 let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
352
353 let resp = self
354 .client
355 .get(&url)
356 .send()
357 .await
358 .map_err(|e| format!("HTTP request failed: {}", e))?;
359
360 if resp.status() == reqwest::StatusCode::NOT_FOUND {
361 return Err("DID not found".to_string());
362 }
363
364 if !resp.status().is_success() {
365 return Err(format!("HTTP {}", resp.status()));
366 }
367
368 resp.json::<serde_json::Value>()
369 .await
370 .map_err(|e| format!("Failed to parse DID document: {}", e))
371 }
372
373 pub async fn invalidate_cache(&self, did: &str) {
374 let mut cache = self.did_cache.write().await;
375 cache.remove(did);
376 drop(cache);
377 let mut doc_cache = self.did_doc_cache.write().await;
378 doc_cache.remove(did);
379 }
380}
381
382impl Default for DidResolver {
383 fn default() -> Self {
384 Self::new()
385 }
386}
387
388pub fn create_did_resolver() -> Arc<DidResolver> {
389 Arc::new(DidResolver::new())
390}