solid_pod_rs/interop.rs
1//! Interop / discovery helpers.
2//!
3//! This module rounds out the crate's public Solid surface with small,
4//! framework-agnostic helpers for ecosystem discovery flows:
5//!
6//! - **`.well-known/solid`** — Solid Protocol §4.1.2 discovery document.
7//! - **WebFinger** — RFC 7033, used to map acct: URIs to WebIDs.
8//! - **NIP-05 verification** — Nostr pubkey ↔ DNS name binding.
9//! - **Dev-mode session bypass** — consumer-crate helper for tests.
10//!
11//! None of these helpers perform network I/O on their own; they return
12//! response bodies and signal objects that the consumer crate wires
13//! into its HTTP server.
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::PodError;
18
19// ---------------------------------------------------------------------------
20// .well-known/solid discovery document
21// ---------------------------------------------------------------------------
22
23/// Solid Protocol `.well-known/solid` discovery document. The doc
24/// advertises the OIDC issuer, the pod URL, and the Notifications
25/// endpoint. JSS parity: includes `api.accounts` URLs.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SolidWellKnown {
28 #[serde(rename = "@context")]
29 pub context: serde_json::Value,
30
31 pub solid_oidc_issuer: String,
32
33 pub notification_gateway: String,
34
35 pub storage: String,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub webfinger: Option<String>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub api: Option<SolidWellKnownApi>,
42}
43
44/// JSS-compatible account management API pointers.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SolidWellKnownApi {
47 pub accounts: SolidWellKnownAccounts,
48}
49
50/// JSS-compatible account endpoint URLs.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SolidWellKnownAccounts {
53 pub new: String,
54 pub recover: String,
55 pub signin: String,
56 pub signout: String,
57}
58
59/// Build the discovery document for a pod root.
60pub fn well_known_solid(pod_base: &str, oidc_issuer: &str) -> SolidWellKnown {
61 let base = pod_base.trim_end_matches('/');
62 SolidWellKnown {
63 context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
64 solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
65 notification_gateway: format!("{base}/.notifications"),
66 storage: format!("{base}/"),
67 webfinger: Some(format!("{base}/.well-known/webfinger")),
68 api: Some(SolidWellKnownApi {
69 accounts: SolidWellKnownAccounts {
70 new: format!("{base}/api/accounts/new"),
71 recover: format!("{base}/api/accounts/recover"),
72 signin: format!("{base}/login"),
73 signout: format!("{base}/logout"),
74 },
75 }),
76 }
77}
78
79// ---------------------------------------------------------------------------
80// WebFinger (RFC 7033)
81// ---------------------------------------------------------------------------
82
83/// WebFinger JRD (JSON Resource Descriptor) response.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WebFingerJrd {
86 pub subject: String,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub aliases: Vec<String>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub links: Vec<WebFingerLink>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct WebFingerLink {
95 pub rel: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub href: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
99 pub content_type: Option<String>,
100}
101
102/// Produce a WebFinger JRD response pointing `acct:user@host` at the
103/// user's WebID. Returns `None` if the resource is not recognised.
104pub fn webfinger_response(resource: &str, pod_base: &str, webid: &str) -> Option<WebFingerJrd> {
105 if !resource.starts_with("acct:") && !resource.starts_with("https://") {
106 return None;
107 }
108 let base = pod_base.trim_end_matches('/');
109 Some(WebFingerJrd {
110 subject: resource.to_string(),
111 aliases: vec![webid.to_string()],
112 links: vec![
113 WebFingerLink {
114 rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
115 href: Some(format!("{base}/")),
116 content_type: None,
117 },
118 WebFingerLink {
119 rel: "http://www.w3.org/ns/solid#webid".to_string(),
120 href: Some(webid.to_string()),
121 content_type: None,
122 },
123 WebFingerLink {
124 rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
125 href: Some(format!("{base}/")),
126 content_type: None,
127 },
128 ],
129 })
130}
131
132// ---------------------------------------------------------------------------
133// NIP-05 verification
134// ---------------------------------------------------------------------------
135
136/// NIP-05 response document (the JSON served at
137/// `.well-known/nostr.json?name=<local>`).
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Nip05Document {
140 pub names: std::collections::HashMap<String, String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
143}
144
145/// Verify a NIP-05 identifier (`local@example.com`) against a fetched
146/// NIP-05 document. Returns the resolved hex pubkey on success.
147pub fn verify_nip05(identifier: &str, document: &Nip05Document) -> Result<String, PodError> {
148 let (local, _domain) = identifier
149 .split_once('@')
150 .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
151 let lookup = if local.is_empty() { "_" } else { local };
152 let pubkey = document
153 .names
154 .get(lookup)
155 .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
156 if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
157 return Err(PodError::Nip98(format!(
158 "NIP-05 pubkey malformed for {identifier}"
159 )));
160 }
161 Ok(pubkey.clone())
162}
163
164/// Build the NIP-05 document structure for a pod's own hosted names.
165pub fn nip05_document(names: impl IntoIterator<Item = (String, String)>) -> Nip05Document {
166 Nip05Document {
167 names: names.into_iter().collect(),
168 relays: None,
169 }
170}
171
172// ---------------------------------------------------------------------------
173// Dev-mode session bypass
174// ---------------------------------------------------------------------------
175
176/// Dev-mode session — ergonomic handle a consumer crate can plug into
177/// its request-processing pipeline in place of NIP-98/OIDC verification
178/// during tests or local development. The bypass is only constructable
179/// via explicit allow, never through a header the client supplies.
180#[derive(Debug, Clone)]
181pub struct DevSession {
182 pub webid: String,
183 pub pubkey: Option<String>,
184 pub is_admin: bool,
185}
186
187/// Build a dev-session bypass. Callers are expected to gate this on a
188/// top-level `ENABLE_DEV_SESSION=1` or similar environment check —
189/// the helper itself will not read env to avoid accidental activation.
190pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
191 DevSession {
192 webid: webid.into(),
193 pubkey: None,
194 is_admin,
195 }
196}
197
198// ---------------------------------------------------------------------------
199// Tests
200// ---------------------------------------------------------------------------
201
202// These unit tests sit next to the interop functions they cover; the
203// did:nostr resolver and NodeInfo surfaces below were appended later. When
204// `did-nostr` is cfg'd out (e.g. the oidc/s3 CI feature cells) this becomes a
205// test module followed by items, which is intentional here.
206#[allow(clippy::items_after_test_module)]
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn well_known_solid_advertises_oidc_and_storage() {
213 let d = well_known_solid("https://pod.example/", "https://op.example/");
214 assert_eq!(d.solid_oidc_issuer, "https://op.example");
215 assert!(d.notification_gateway.ends_with(".notifications"));
216 assert!(d.storage.ends_with('/'));
217 }
218
219 #[test]
220 fn webfinger_returns_links_for_acct() {
221 let j = webfinger_response(
222 "acct:alice@pod.example",
223 "https://pod.example",
224 "https://pod.example/profile/card#me",
225 )
226 .unwrap();
227 assert_eq!(j.subject, "acct:alice@pod.example");
228 assert!(j
229 .links
230 .iter()
231 .any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
232 }
233
234 #[test]
235 fn webfinger_rejects_unknown_scheme() {
236 assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
237 }
238
239 #[test]
240 fn nip05_verify_returns_pubkey() {
241 let mut names = std::collections::HashMap::new();
242 names.insert("alice".to_string(), "a".repeat(64));
243 let doc = nip05_document(names);
244 let pk = verify_nip05("alice@pod.example", &doc).unwrap();
245 assert_eq!(pk, "a".repeat(64));
246 }
247
248 #[test]
249 fn nip05_verify_rejects_malformed_pubkey() {
250 let mut names = std::collections::HashMap::new();
251 names.insert("alice".to_string(), "shortkey".to_string());
252 let doc = nip05_document(names);
253 assert!(verify_nip05("alice@p", &doc).is_err());
254 }
255
256 #[test]
257 fn nip05_root_name_resolves_via_underscore() {
258 let mut names = std::collections::HashMap::new();
259 names.insert("_".to_string(), "b".repeat(64));
260 let doc = nip05_document(names);
261 assert!(verify_nip05("@pod.example", &doc).is_ok());
262 }
263
264 #[test]
265 fn dev_session_stores_admin_flag() {
266 let s = dev_session("https://me/profile#me", true);
267 assert!(s.is_admin);
268 assert_eq!(s.webid, "https://me/profile#me");
269 }
270}
271
272// ---------------------------------------------------------------------------
273// did:nostr resolver (Sprint 6 D)
274// ---------------------------------------------------------------------------
275
276/// did:nostr resolver — DID-Doc publication + bidirectional
277/// `alsoKnownAs`/`owl:sameAs` verification.
278///
279/// Mirrors `JavaScriptSolidServer/src/auth/did-nostr.js`: given
280/// `did:nostr:<pubkey>` hosted on an origin, fetch
281/// `https://<origin>/.well-known/did/nostr/<pubkey>.json`, iterate the
282/// `alsoKnownAs` entries, fetch each candidate WebID profile, and
283/// verify it carries an `owl:sameAs` / `schema:sameAs` back-link to
284/// `did:nostr:<pubkey>`. Only a verified WebID is returned.
285///
286/// Defence-in-depth: every outbound request (DID Doc + each WebID
287/// candidate) runs through the configured [`SsrfPolicy`](crate::security::ssrf::SsrfPolicy) before
288/// network I/O. A small in-memory TTL cache covers both success and
289/// negative results so a dark origin does not hammer the downstream.
290#[cfg(feature = "did-nostr")]
291pub mod did_nostr {
292 use std::collections::HashMap;
293 use std::sync::{Arc, RwLock};
294 use std::time::{Duration, Instant};
295
296 use reqwest::Client;
297 use serde::{Deserialize, Serialize};
298 use url::Url;
299
300 use crate::security::ssrf::SsrfPolicy;
301
302 /// Compose the well-known DID Doc location for a Nostr pubkey
303 /// hosted on a given origin. Mirrors JSS `did-nostr.js:79` where
304 /// the resolver URL is `<base>/<pubkey>.json`.
305 pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
306 format!(
307 "{}/.well-known/did/nostr/{}.json",
308 origin.trim_end_matches('/'),
309 pubkey
310 )
311 }
312
313 /// Build the canonical `did:nostr` DID Doc for publication at the
314 /// well-known URL (ADR-125 — supersedes ADR-074 §D2/§D3/§D4/§D13).
315 ///
316 /// The single published form: `@context`
317 /// `["https://www.w3.org/ns/did/v1", "https://www.w3.org/ns/cid/v1",
318 /// "https://w3id.org/nostr/context"]` (did:nostr CG 0.1.1 — DID Core
319 /// context first, as DID Core requires),
320 /// top-level `type: "DIDNostr"`, a single `Multikey` verification method
321 /// with `publicKeyMultibase: "fe70102<hex>"`, fragment `#key1`. Optional
322 /// members are omitted when empty (no `service: []`), per the did:nostr CG
323 /// spec (<https://nostrcg.github.io/did-nostr/>). The 2019 suite +
324 /// `publicKeyHex` + `z`-base58 multibase are dropped. No dual-publish.
325 ///
326 /// `also_known_as` is surfaced as a top-level `alsoKnownAs` link when
327 /// non-empty — the spec's canonical location for cross-platform identity
328 /// (WebID / ActivityPub / AT-proto).
329 ///
330 /// NOTE: The canonical DID:nostr types (including `NostrPubkey`,
331 /// `render_did_document`, `render_did_document_tier3`) live in
332 /// [`crate::did_nostr_types`] (feature `did-nostr-types`). This
333 /// convenience wrapper accepts a raw hex string and merges
334 /// `also_known_as` into the canonical skeleton. New code should prefer
335 /// the canonical types directly.
336 pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
337 // Delegate to the canonical renderer when the pubkey parses cleanly;
338 // fall back to inline JSON for malformed input (preserves
339 // backward-compat — callers never saw an error here).
340 #[cfg(feature = "did-nostr-types")]
341 if let Ok(pk) = crate::did_nostr_types::NostrPubkey::from_hex(pubkey) {
342 let mut doc = crate::did_nostr_types::render_did_document(&pk);
343 if !also_known_as.is_empty() {
344 doc["alsoKnownAs"] = serde_json::json!(also_known_as);
345 }
346 return doc;
347 }
348
349 // Fallback for malformed hex (D-1 fix, ADR-124 §7 / I2).
350 //
351 // A malformed pubkey cannot produce the `fe70102` framing, so we must
352 // NEVER emit a `Multikey` verification method without its
353 // `publicKeyMultibase` — a keyless `Multikey` is an I2 violation (the
354 // VM would advertise a key type it cannot back). The signature is
355 // infallible by contract (callers never saw an error here), so instead
356 // of erroring we emit the canonical envelope with an EMPTY
357 // `verificationMethod` (and, in lockstep, empty `authentication` /
358 // `assertionMethod`, since `#key1` no longer resolves). The
359 // `did:nostr:<pubkey>` body and the canonical context/type are
360 // preserved verbatim. Well-formed input always takes the delegating
361 // branch above and carries the full `Multikey` VM.
362 let did = format!("did:nostr:{pubkey}");
363 let mut doc = serde_json::json!({
364 "@context": [
365 "https://www.w3.org/ns/did/v1",
366 "https://www.w3.org/ns/cid/v1",
367 "https://w3id.org/nostr/context"
368 ],
369 "id": did,
370 "type": "DIDNostr",
371 "verificationMethod": [],
372 "authentication": [],
373 "assertionMethod": []
374 });
375 if !also_known_as.is_empty() {
376 doc["alsoKnownAs"] = serde_json::json!(also_known_as);
377 }
378 doc
379 }
380
381 /// Parsed DID Doc. Only the subset of fields relevant to WebID
382 /// resolution is typed; unknown fields are ignored.
383 #[derive(Debug, Clone, Deserialize, Serialize)]
384 pub struct DidNostrDoc {
385 pub id: String,
386 #[serde(default, rename = "alsoKnownAs")]
387 pub also_known_as: Vec<String>,
388 }
389
390 /// TTL-cached `did:nostr:<pubkey>` → WebID resolver with per-hop
391 /// SSRF enforcement.
392 pub struct DidNostrResolver {
393 ssrf: Arc<SsrfPolicy>,
394 client: Client,
395 cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
396 success_ttl: Duration,
397 failure_ttl: Duration,
398 }
399
400 struct CachedEntry {
401 fetched: Instant,
402 web_id: Option<String>,
403 }
404
405 impl DidNostrResolver {
406 /// Construct a resolver with the default HTTP client (10 s
407 /// timeout) and TTLs matching JSS (5 min success, 1 min
408 /// failure).
409 pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
410 let client = Client::builder()
411 .timeout(Duration::from_secs(10))
412 .build()
413 .unwrap_or_else(|_| Client::new());
414 Self {
415 ssrf,
416 client,
417 cache: Arc::new(RwLock::new(HashMap::new())),
418 success_ttl: Duration::from_secs(300),
419 failure_ttl: Duration::from_secs(60),
420 }
421 }
422
423 /// Override the default success / failure cache TTLs.
424 pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
425 self.success_ttl = success;
426 self.failure_ttl = failure;
427 self
428 }
429
430 /// Resolve `did:nostr:<pubkey>` against `origin` to a verified
431 /// WebID. Returns `None` if:
432 ///
433 /// - SSRF policy denies the origin or any WebID candidate.
434 /// - DID Doc fetch fails or the doc's `id` does not match
435 /// `did:nostr:<pubkey>`.
436 /// - `alsoKnownAs` is empty.
437 /// - No candidate WebID carries a back-link (`owl:sameAs` or
438 /// `schema:sameAs`) to the same `did:nostr:<pubkey>`.
439 ///
440 /// Both success and failure are cached; subsequent calls
441 /// within the matching TTL are served from memory without
442 /// network I/O.
443 pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
444 let cache_key = format!("{origin}|{pubkey}");
445
446 // Cache lookup (read lock).
447 if let Ok(guard) = self.cache.read() {
448 if let Some(entry) = guard.get(&cache_key) {
449 let ttl = if entry.web_id.is_some() {
450 self.success_ttl
451 } else {
452 self.failure_ttl
453 };
454 if entry.fetched.elapsed() < ttl {
455 return entry.web_id.clone();
456 }
457 }
458 }
459
460 let result = self.resolve_uncached(origin, pubkey).await;
461
462 if let Ok(mut guard) = self.cache.write() {
463 guard.insert(
464 cache_key,
465 CachedEntry {
466 fetched: Instant::now(),
467 web_id: result.clone(),
468 },
469 );
470 }
471
472 result
473 }
474
475 async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
476 // 1. SSRF check on origin.
477 let origin_url = Url::parse(origin).ok()?;
478 self.ssrf.resolve_and_check(&origin_url).await.ok()?;
479
480 // 2. Fetch DID Doc.
481 let url = did_nostr_well_known_url(origin, pubkey);
482 let resp = self
483 .client
484 .get(&url)
485 .header("accept", "application/did+json, application/json")
486 .send()
487 .await
488 .ok()?
489 .error_for_status()
490 .ok()?;
491 let doc: DidNostrDoc = resp.json().await.ok()?;
492
493 if doc.id != format!("did:nostr:{pubkey}") {
494 return None;
495 }
496
497 // 3. Iterate candidates; return the first verified WebID.
498 let did_iri = format!("did:nostr:{pubkey}");
499 for candidate in &doc.also_known_as {
500 if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
501 return Some(web_id);
502 }
503 }
504 None
505 }
506
507 async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
508 let url = Url::parse(candidate).ok()?;
509 self.ssrf.resolve_and_check(&url).await.ok()?;
510 let resp = self
511 .client
512 .get(url.as_str())
513 .header("accept", "text/turtle, application/ld+json")
514 .send()
515 .await
516 .ok()?
517 .error_for_status()
518 .ok()?;
519 let body = resp.text().await.ok()?;
520
521 // Back-link check — literal string match suffices for the
522 // bidirectional guarantee because the DID IRI is by spec a
523 // verbatim literal (no relativisation in either RDF flavour).
524 let has_predicate = body.contains("owl:sameAs")
525 || body.contains("schema:sameAs")
526 || body.contains("http://www.w3.org/2002/07/owl#sameAs")
527 || body.contains("https://schema.org/sameAs");
528 if has_predicate && body.contains(did_iri) {
529 Some(candidate.to_string())
530 } else {
531 None
532 }
533 }
534 }
535}
536
537// ---------------------------------------------------------------------------
538// NodeInfo 2.1 (Sprint 7 C)
539// ---------------------------------------------------------------------------
540
541/// `/.well-known/nodeinfo` discovery document (JSON), per
542/// nodeinfo.diaspora.software §6. Points clients at one or more
543/// versioned NodeInfo docs.
544pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
545 serde_json::json!({
546 "links": [
547 {
548 "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
549 "href": format!(
550 "{}/.well-known/nodeinfo/2.1",
551 base_url.trim_end_matches('/')
552 )
553 }
554 ]
555 })
556}
557
558/// `/.well-known/nodeinfo/2.1` content document, per
559/// nodeinfo.diaspora.software §3 (schema 2.1).
560pub fn nodeinfo_2_1(
561 software_name: &str,
562 software_version: &str,
563 open_registrations: bool,
564 total_users: u64,
565) -> serde_json::Value {
566 serde_json::json!({
567 "version": "2.1",
568 "software": {
569 "name": software_name,
570 "version": software_version,
571 "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
572 "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
573 },
574 "protocols": ["solid", "activitypub"],
575 "services": {
576 "inbound": [],
577 "outbound": []
578 },
579 "openRegistrations": open_registrations,
580 "usage": {
581 "users": {
582 "total": total_users
583 }
584 },
585 "metadata": {}
586 })
587}