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 if let Some(service) = doc.service.iter().find(|s| {
235 s.service_type == "AtprotoAppView"
236 || s.id.contains("atproto_appview")
237 || s.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 if let Some(service) = doc
246 .service
247 .iter()
248 .find(|s| s.service_type.contains("AppView") || s.id.contains("appview"))
249 {
250 return Some(ResolvedService {
251 url: service.service_endpoint.clone(),
252 did: doc.id.clone(),
253 });
254 }
255
256 if let Some(service) = doc.service.first()
257 && service.service_endpoint.starts_with("http")
258 {
259 warn!(
260 "No explicit AppView service found for {}, using first service: {}",
261 doc.id, service.service_endpoint
262 );
263 return Some(ResolvedService {
264 url: service.service_endpoint.clone(),
265 did: doc.id.clone(),
266 });
267 }
268
269 if doc.id.starts_with("did:web:") {
270 let host = doc.id.strip_prefix("did:web:")?;
271 let decoded_host = host.replace("%3A", ":");
272 let base_host = decoded_host.split('/').next()?;
273 let scheme = if base_host.starts_with("localhost")
274 || base_host.starts_with("127.0.0.1")
275 || base_host.contains(':')
276 {
277 "http"
278 } else {
279 "https"
280 };
281 warn!(
282 "No service found for {}, deriving URL from DID: {}://{}",
283 doc.id, scheme, base_host
284 );
285 return Some(ResolvedService {
286 url: format!("{}://{}", scheme, base_host),
287 did: doc.id.clone(),
288 });
289 }
290
291 None
292 }
293
294 pub async fn resolve_did_document(&self, did: &str) -> Option<serde_json::Value> {
295 {
296 let cache = self.did_doc_cache.read().await;
297 if let Some(cached) = cache.get(did)
298 && cached.resolved_at.elapsed() < self.cache_ttl
299 {
300 return Some(cached.document.clone());
301 }
302 }
303
304 let result = if did.starts_with("did:web:") {
305 self.fetch_did_document_web(did).await
306 } else if did.starts_with("did:plc:") {
307 self.fetch_did_document_plc(did).await
308 } else {
309 warn!("Unsupported DID method for document resolution: {}", did);
310 return None;
311 };
312
313 match result {
314 Ok(doc) => {
315 let mut cache = self.did_doc_cache.write().await;
316 cache.insert(
317 did.to_string(),
318 CachedDidDocument {
319 document: doc.clone(),
320 resolved_at: Instant::now(),
321 },
322 );
323 Some(doc)
324 }
325 Err(e) => {
326 warn!("Failed to resolve DID document for {}: {}", did, e);
327 None
328 }
329 }
330 }
331
332 async fn fetch_did_document_web(&self, did: &str) -> Result<serde_json::Value, String> {
333 let url = Self::build_did_web_url(did)?;
334
335 let resp = self
336 .client
337 .get(&url)
338 .send()
339 .await
340 .map_err(|e| format!("HTTP request failed: {}", e))?;
341
342 if !resp.status().is_success() {
343 return Err(format!("HTTP {}", resp.status()));
344 }
345
346 resp.json::<serde_json::Value>()
347 .await
348 .map_err(|e| format!("Failed to parse DID document: {}", e))
349 }
350
351 async fn fetch_did_document_plc(&self, did: &str) -> Result<serde_json::Value, String> {
352 let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
353
354 let resp = self
355 .client
356 .get(&url)
357 .send()
358 .await
359 .map_err(|e| format!("HTTP request failed: {}", e))?;
360
361 if resp.status() == reqwest::StatusCode::NOT_FOUND {
362 return Err("DID not found".to_string());
363 }
364
365 if !resp.status().is_success() {
366 return Err(format!("HTTP {}", resp.status()));
367 }
368
369 resp.json::<serde_json::Value>()
370 .await
371 .map_err(|e| format!("Failed to parse DID document: {}", e))
372 }
373
374 pub async fn invalidate_cache(&self, did: &str) {
375 let mut cache = self.did_cache.write().await;
376 cache.remove(did);
377 drop(cache);
378 let mut doc_cache = self.did_doc_cache.write().await;
379 doc_cache.remove(did);
380 }
381}
382
383impl Default for DidResolver {
384 fn default() -> Self {
385 Self::new()
386 }
387}
388
389pub fn create_did_resolver() -> Arc<DidResolver> {
390 Arc::new(DidResolver::new())
391}