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