//! The host `call` (javascript/src/server hostCall + login, didkey didkeyCall) and loadModule/pdsOf //! from run-module: own PDS, atproto-proxy (signed out: a query straight to the did#id), https service //! with getServiceAuth, DID-service reads, //! subscriptions, attest and did:key pseudo-calls. Transports (iroh) are crates on crate::transport. use std::sync::{Arc, Mutex}; use serde_json::{Map, Value, json}; use crate::crypto::{self, ATTEST_NSID, DidKey}; use crate::run::{Call, Module, Request, Sub}; use crate::store::Store; use crate::{BoxFut, iso, js_string, transport, ws}; const MODULE_COLLECTION: &str = "com.fedfork.exec.wasm.module"; pub struct Response { pub status: u16, pub headers: Vec<(String, String)>, pub body: Vec, } impl Response { fn header(&self, name: &str) -> Option<&str> { self.headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case(name)) .map(|(_, v)| v.as_str()) } } /// ctx.http.fetch: swappable so tests and the conformance bridge run without a network. pub trait Fetch: Send + Sync { fn fetch( &self, method: &str, url: &str, headers: Vec<(String, String)>, body: Option>, ) -> BoxFut<'_, Result>; } impl Fetch for reqwest::Client { fn fetch( &self, method: &str, url: &str, headers: Vec<(String, String)>, body: Option>, ) -> BoxFut<'_, Result> { let mut request = self.request(method.parse().unwrap_or(reqwest::Method::GET), url); for (k, v) in headers { request = request.header(k, v); } if let Some(body) = body { request = request.body(body); } Box::pin(async move { let response = request .send() .await .map_err(|e| format!("fetch failed: {e}"))?; let status = response.status().as_u16(); let headers = response .headers() .iter() .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string())) .collect(); let body = response.bytes().await.map_err(|e| e.to_string())?.to_vec(); Ok(Response { status, headers, body, }) }) } } pub struct XrpcError { pub message: String, pub error: Option, } /// text/event-stream frames as [{ event, data }] ("message" when a frame names none), data parsed when it is /// JSON: host-call.js sseEvents. pub fn sse_events(text: &str) -> Value { let text = text.replace("\r\n", "\n"); let events = text.split("\n\n").filter_map(|frame| { let mut event = "message".to_string(); let mut data = Vec::new(); for line in frame.lines() { if let Some(name) = line.strip_prefix("event:") { event = name.trim().to_string(); } else if let Some(d) = line.strip_prefix("data:") { data.push(d.strip_prefix(' ').unwrap_or(d)); } } (!data.is_empty()).then(|| { let raw = data.join("\n"); json!({ "event": event, "data": serde_json::from_str::(&raw).unwrap_or(Value::String(raw)) }) }) }); Value::Array(events.collect()) } /// The JSON body of an XRPC response, or its error: " failed with [: message]". /// A text/event-stream answer comes back as `{ events: sse_events(body) }`, a binary one (a /// content-type neither JSON nor text: getBlob, a CAR) as `{ $bytes }` (JS json). pub fn json_response(nsid: &str, response: &Response) -> Result { let ok = (200..300).contains(&response.status); let content_type = response.header("content-type").unwrap_or_default().to_ascii_lowercase(); if ok && content_type.starts_with("text/event-stream") { return Ok(json!({ "events": sse_events(&String::from_utf8_lossy(&response.body)) })); } if ok && !content_type.is_empty() && !content_type.contains("json") && !content_type.starts_with("text/") { return Ok(json!({ "$bytes": base64::Engine::encode(&base64::engine::general_purpose::STANDARD_NO_PAD, &response.body) })); } let body: Value = serde_json::from_slice(&response.body).unwrap_or_else(|_| json!({})); if (200..300).contains(&response.status) { return Ok(body); } let error = body .get("error") .filter(|e| !e.is_null()) .map(|e| js_string(Some(e))); let truthy = |v: &&Value| match v { Value::Null => false, Value::Bool(b) => *b, Value::Number(n) => n.as_f64() != Some(0.0), Value::String(s) => !s.is_empty(), _ => true, }; let message = body .get("message") .filter(truthy) .map(|m| format!(": {}", js_string(Some(m)))) .unwrap_or_default(); // A 429 says when it lifts (ratelimit-reset: unix seconds), so nobody has to probe for it. let until = match ( response.status, response.header("ratelimit-reset").filter(|r| !r.is_empty()), ) { (429, Some(reset)) => format!( " ({}; resets {})", response.header("ratelimit-policy").unwrap_or("limit"), iso(reset.trim().parse::().unwrap_or(0) * 1000) ), _ => String::new(), }; Err(XrpcError { message: format!( "{nsid} failed with {}{message}{until}", error.clone().unwrap_or(response.status.to_string()) ), error, }) } pub fn xrpc_url( base: &str, nsid: &str, params: Option<&Map>, ) -> Result { let mut url = reqwest::Url::parse(base) .and_then(|b| b.join(&format!("/xrpc/{nsid}"))) .map_err(|e| format!("Invalid URL {base}: {e}"))?; if let Some(params) = params { url.query_pairs_mut() .clear() .extend_pairs(params.iter().map(|(k, v)| (k, js_string(Some(v))))); } Ok(url.to_string()) } /// JS xrpcBody: a request body and its content-type. `{ $bytes }` input goes as those bytes (`encoding`, /// default application/octet-stream: uploadBlob), any other input as JSON. pub fn xrpc_body(input: Option<&Value>, encoding: Option<&str>) -> Result, String)>, String> { let Some(input) = input else { return Ok(None) }; match input.as_object().filter(|o| o.len() == 1).and_then(|o| o.get("$bytes")).and_then(Value::as_str) { Some(b64) => { let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, b64.trim_end_matches('=')).map_err(|e| format!("$bytes is not base64: {e}"))?; Ok(Some((bytes, encoding.unwrap_or("application/octet-stream").to_string()))) } None => Ok(Some((input.to_string().into_bytes(), "application/json".into()))), } } async fn get_json(fetch: &dyn Fetch, nsid: &str, url: &str) -> Result { json_response(nsid, &fetch.fetch("GET", url, vec![], None).await?).map_err(|e| e.message) } /// JS privateHost: loopback, private, link-local, shared, multicast and unspecified hosts /// (localhost and IPv4 / IPv6 literals). ponytail: literals only, as the JS host; a public name /// that resolves to one is not caught (needs a resolver hook). pub fn private_host(host: &url::Host<&str>) -> bool { match host { url::Host::Domain(d) => { let d = d.trim_end_matches('.').to_ascii_lowercase(); d.is_empty() || d == "localhost" || d.ends_with(".localhost") } url::Host::Ipv4(ip) => { let [a, b, ..] = ip.octets(); a == 0 || a == 10 || a == 127 || a >= 224 || (a == 169 && b == 254) || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 168) || (a == 100 && (64..=127).contains(&b)) } url::Host::Ipv6(ip) => { let first = ip.segments()[0]; first == 0 || first & 0xfe00 == 0xfc00 || first & 0xffc0 == 0xfe80 || first & 0xff00 == 0xff00 } } } /// ATLIBS_ALLOW_PRIVATE=1: DID lookups may reach http and private addresses (local dev, tests). fn allow_private() -> bool { std::env::var("ATLIBS_ALLOW_PRIVATE").is_ok_and(|v| v == "1") } /// JS publicUrl: a DID document names its own endpoints, and DID reads run for anonymous visitors /// (ingress), so a lookup must not reach the host's own network: https, and not a private host. pub fn public_url(url: &str, allow_private: bool) -> Result { let parsed = url::Url::parse(url).map_err(|_| format!("{url} is not a URL"))?; if allow_private { return Ok(url.to_string()); } if parsed.scheme() != "https" { return Err(format!("{url} is not https")); } if parsed.host().is_none_or(|h| private_host(&h)) { return Err(format!("{url} is a private address")); } Ok(url.to_string()) } /// The PDS a did:plc or did:web account lives on, or for did#id that service's endpoint: an https URL /// off the host's network unless ATLIBS_ALLOW_PRIVATE=1. pub async fn pds_of(fetch: &dyn Fetch, reference: &str) -> Result { let (did, id) = reference.split_once('#').unwrap_or((reference, "atproto_pds")); let url = if did.starts_with("did:plc:") { format!("https://plc.directory/{did}") } else if let Some(host) = did.strip_prefix("did:web:") { format!( "https://{}/.well-known/did.json", host.replace("%3A", ":").replace("%3a", ":") ) } else { return Err(format!("{did} is not a did:plc or did:web")); }; let response = fetch .fetch("GET", &public_url(&url, allow_private())?, vec![], None) .await?; if !(200..300).contains(&response.status) { return Err(format!("Could not resolve {did}")); } let doc: Value = serde_json::from_slice(&response.body).map_err(|e| e.to_string())?; let missing = || if id == "atproto_pds" { format!("{did} has no PDS") } else { format!("{did} has no #{id} service") }; public_url(&service_in(&doc, did, id).ok_or_else(missing)?, allow_private()) } fn service_in(doc: &Value, did: &str, id: &str) -> Option { let services = doc.get("service")?.as_array()?; let found = services.iter().find(|s| { s.get("id") .and_then(Value::as_str) .is_some_and(|x| x == format!("#{id}") || x == format!("{did}#{id}")) })?; found .get("serviceEndpoint") .and_then(Value::as_str) .map(String::from) } /// A blob's CID (raw codec, sha2-256, base32 CIDv1) carries the sha256 its bytes must hash to. pub fn blob_sha256(cid: Option<&str>) -> Result { let cid = cid .filter(|c| { c.starts_with('b') && c[1..] .bytes() .all(|b| b"abcdefghijklmnopqrstuvwxyz234567".contains(&b)) }) .ok_or("Blob CID must be base32 CIDv1")?; let bytes = crypto::unbase32(&cid[1..]); if bytes.len() != 36 || bytes[..4] != [0x01, 0x55, 0x12, 0x20] { return Err("Blob CID must be a raw sha2-256 CIDv1".into()); } Ok(crypto::hex(&bytes[4..])) } /// A published module: its com.fedfork.exec.wasm.module record and blob, from the publisher's PDS. pub async fn load_module(fetch: &dyn Fetch, uri: &str) -> Result { let parts: Vec<&str> = uri .strip_prefix("at://") .map(|r| r.split('/').collect()) .unwrap_or_default(); let [did, collection, rkey] = parts[..] else { return Err(format!("{uri} is not a {MODULE_COLLECTION} record")); }; if collection != MODULE_COLLECTION || !did.starts_with("did:") || rkey.is_empty() { return Err(format!("{uri} is not a {MODULE_COLLECTION} record")); } let pds = pds_of(fetch, did).await?; let params = |pairs: &[(&str, &str)]| { pairs .iter() .map(|(k, v)| (k.to_string(), json!(v))) .collect::>() }; let record = fetch .fetch( "GET", &xrpc_url( &pds, "com.atproto.repo.getRecord", Some(¶ms(&[ ("repo", did), ("collection", collection), ("rkey", rkey), ])), )?, vec![], None, ) .await?; if !(200..300).contains(&record.status) { return Err(format!("{uri} not found")); } // any JSON: a reply that is not an object (or has no value) is an unsupported ABI, not a panic let value = serde_json::from_slice::(&record.body) .map_err(|e| e.to_string())? .get_mut("value") .map(Value::take) .unwrap_or(Value::Null); if value.get("abi").and_then(Value::as_str) != Some("emdash-wasm-json-v1") { return Err(format!( "Unsupported module ABI {}", js_string(value.get("abi")) )); } let cid = value.pointer("/module/ref/$link").and_then(Value::as_str); let sha256 = blob_sha256(cid)?; let blob = fetch .fetch( "GET", &xrpc_url( &pds, "com.atproto.sync.getBlob", Some(¶ms(&[("did", did), ("cid", cid.unwrap_or_default())])), )?, vec![], None, ) .await?; if !(200..300).contains(&blob.status) { return Err(format!("{uri} blob not found")); } let mut module = Module::from_manifest(blob.body, &value)?; module.sha256 = Some(sha256); module.id = Some(uri.to_string()); Ok(module) } /// App-password session: createSession at `service`, then XRPC to the account's own PDS. With a /// store, the session is the state dir's saved one (sealed setting "session"; createSession is rate /// limited) and every new one is saved there. Runs share it, in this process and across processes /// sharing the state dir: when a request comes back ExpiredToken or InvalidToken, one renewal runs at a /// time here (`renewing`) and under the store's "session" lease across processes; it first re-reads the /// saved session (another run may have refreshed it already: use that), else refreshes with the newest /// refresh token, and logs in again only if the refresh is refused. Without a store it lives in memory. pub struct Session { identifier: String, password: Option, service: String, fetch: Arc, tokens: Mutex, store: Option, renewing: tokio::sync::Mutex<()>, /// an OAuth session (crate::oauth_client): `identifier` is its DID, `tokens` its saved session /// (DPoP-bound, refreshed at its authorization server, never re-created) oauth: bool, } /// Who a session signs in as: an explicit choice, never read from the environment. /// No config, no session. #[derive(Clone, Debug)] pub enum SessionConfig { /// The simple / testing case: an app password at `service` (an entryway or PDS). The password may /// be None when the state dir holds this identifier's saved session. AppPassword { identifier: String, password: Option, service: String }, /// A user's OAuth session, saved in the state dir by `atlibs login` or a server's /oauth/callback. OAuth { did: String }, } impl SessionConfig { pub async fn open(self, fetch: Arc, store: Option) -> Result, String> { match self { SessionConfig::AppPassword { identifier, password, service } => Session::login(&identifier, password, &service, fetch, store).await, SessionConfig::OAuth { did } => Session::oauth(&did, fetch, store.ok_or("an OAuth session lives in a state dir")?).await, } } } /// How long a process may hold the session lease (a refresh, then maybe a createSession) before others take over. const SESSION_LEASE: std::time::Duration = std::time::Duration::from_secs(30); impl Session { pub async fn login( identifier: &str, password: Option, service: &str, fetch: Arc, store: Option, ) -> Result, String> { let session = Arc::new(Self { identifier: identifier.into(), password, service: service.into(), fetch, tokens: Mutex::new(Value::Null), store, renewing: tokio::sync::Mutex::new(()), oauth: false, }); if !session.adopt_saved()? { // Several processes starting on an empty state dir log in once. let _lease = session.lease().await?; if !session.adopt_saved()? { session.create().await?; } } Ok(session) } /// `did`'s OAuth session, saved in the state dir (oauth_client: `atlibs login`, a server's callback). pub async fn oauth(did: &str, fetch: Arc, store: Store) -> Result, String> { let session = Arc::new(Self { identifier: did.into(), password: None, service: String::new(), fetch, tokens: Mutex::new(Value::Null), store: Some(store), renewing: tokio::sync::Mutex::new(()), oauth: true, }); if !session.adopt_saved()? { return Err(format!("{did} has no OAuth session here: sign in (atlibs login, or the server's /oauth/login)")); } Ok(session) } pub fn is_oauth(&self) -> bool { self.oauth } /// The setting (and the lease) this session is saved under. fn name(&self) -> String { if self.oauth { crate::oauth_client::setting(&self.identifier) } else { "session".into() } } /// The saved session of this identifier, if the store has one; true when it was taken. fn adopt_saved(&self) -> Result { let Some(store) = &self.store else { return Ok(false) }; let saved = store.setting(&self.name())?.filter(|s| { (self.oauth || s.get("identifier").and_then(Value::as_str) == Some(&self.identifier)) && s.get("refreshJwt").is_some_and(|r| r.as_str().is_some_and(|r| !r.is_empty())) }); let found = saved.is_some(); if let Some(saved) = saved { *self.tokens.lock().unwrap() = saved; } Ok(found) } async fn lease(&self) -> Result, String> { match &self.store { Some(store) => store.lock(&self.name(), SESSION_LEASE).await.map(Some), None => Ok(None), } } fn access(&self) -> String { js_string(self.tokens.lock().unwrap().get("accessJwt")) } /// `used` (the access token a request carried) was refused: renew it once for every run sharing it. async fn renew(&self, used: &str) -> Result<(), String> { let _one = self.renewing.lock().await; if self.access() != used { return Ok(()); // another run here renewed it while this one waited } let _lease = self.lease().await?; if self.adopt_saved()? && self.access() != used { return Ok(()); // another process renewed it } self.refresh().await } pub fn did(&self) -> String { js_string(self.tokens.lock().unwrap().get("did")) } pub fn pds(&self) -> String { let tokens = self.tokens.lock().unwrap(); if let Some(pds) = tokens.get("pds").and_then(Value::as_str) { return pds.to_string(); } tokens .get("didDoc") .and_then(|doc| service_in(doc, &js_string(tokens.get("did")), "atproto_pds")) .unwrap_or_else(|| self.service.clone()) } fn save(&self, session: Value) -> Result<(), String> { if self.oauth { *self.tokens.lock().unwrap() = session.clone(); return self.store.as_ref().map_or(Ok(()), |store| store.set_setting(&self.name(), &session)); } let mut tokens = json!({ "identifier": self.identifier }); if let Value::Object(fields) = session { tokens.as_object_mut().unwrap().extend(fields); } *self.tokens.lock().unwrap() = tokens.clone(); match &self.store { Some(store) => store.set_setting("session", &tokens), None => Ok(()), } } async fn post( &self, url: &str, nsid: &str, bearer: Option, input: Option, ) -> Result { let mut headers = vec![]; if let Some(b) = bearer { headers.push(("authorization".into(), format!("Bearer {b}"))); } if input.is_some() { headers.push(("content-type".into(), "application/json".into())); } let response = self .fetch .fetch( "POST", url, headers, input.map(|i| i.to_string().into_bytes()), ) .await .map_err(|message| XrpcError { message, error: None, })?; json_response(nsid, &response) } async fn create(&self) -> Result<(), String> { let nsid = "com.atproto.server.createSession"; let input = json!({ "identifier": self.identifier, "password": self.password }); // stderr, so the rate-limited logins are visible (stdout stays the result) eprintln!("atlibs: createSession for {}", self.identifier); let session = self .post( &xrpc_url(&self.service, nsid, None)?, nsid, None, Some(input), ) .await .map_err(|e| e.message)?; self.save(session) } async fn refresh(&self) -> Result<(), String> { if self.oauth { let saved = self.tokens.lock().unwrap().clone(); eprintln!("atlibs: refreshing the OAuth session of {}", self.identifier); let fresh = crate::oauth_client::refresh(self.fetch.as_ref(), &saved, self.store.as_ref()) .await .map_err(|e| format!("{}: the OAuth session cannot be refreshed ({e}); sign in again", self.identifier))?; return self.save(fresh); } let nsid = "com.atproto.server.refreshSession"; let refresh = js_string(self.tokens.lock().unwrap().get("refreshJwt")); eprintln!( "atlibs: refreshing the saved session for {}", self.identifier ); match self .post( &xrpc_url(&self.pds(), nsid, None)?, nsid, Some(refresh), None, ) .await { Ok(session) => self.save(session), Err(_) => self.create().await, } } pub async fn xrpc( &self, nsid: &str, params: Option<&Map>, input: Option<&Value>, headers: Vec<(String, String)>, ) -> Result { let body = input.map(|i| (i.to_string().into_bytes(), "application/json")); self.send(nsid, params, body, headers).await } /// XRPC with a body xrpc_body made (bytes or JSON, with its content-type). pub async fn xrpc_with( &self, nsid: &str, params: Option<&Map>, body: Option<(Vec, String)>, headers: Vec<(String, String)>, ) -> Result { let body = body.as_ref().map(|(b, t)| (b.clone(), t.as_str())); self.send(nsid, params, body, headers).await } /// com.atproto.repo.uploadBlob: the bytes as the body (atlibs publish). pub async fn upload_blob(&self, bytes: Vec, mime: &str) -> Result { self.send("com.atproto.repo.uploadBlob", None, Some((bytes, mime)), vec![]) .await } async fn send( &self, nsid: &str, params: Option<&Map>, body: Option<(Vec, &str)>, headers: Vec<(String, String)>, ) -> Result { if self.oauth { return self.send_oauth(nsid, params, body, headers).await; } for retried in [false, true] { let access = self.access(); let mut all = headers.clone(); all.push(("authorization".into(), format!("Bearer {access}"))); if let Some((_, content_type)) = &body { all.push(("content-type".into(), content_type.to_string())); } let url = xrpc_url(&self.pds(), nsid, params)?; let response = self .fetch .fetch( if body.is_some() { "POST" } else { "GET" }, &url, all, body.as_ref().map(|(b, _)| b.clone()), ) .await?; match json_response(nsid, &response) { Ok(body) => return Ok(body), Err(e) if !retried && matches!(e.error.as_deref(), Some("ExpiredToken" | "InvalidToken")) => { self.renew(&access).await? } Err(e) => return Err(e.message), } } unreachable!("the second try returns") } /// XRPC with the DPoP-bound access token: renewed first when it expires within a minute (one /// refresh for every run sharing it), a use_dpop_nonce answer sent again with the new nonce, and a /// refused token renewed once. async fn send_oauth( &self, nsid: &str, params: Option<&Map>, body: Option<(Vec, &str)>, headers: Vec<(String, String)>, ) -> Result { use crate::oauth_client::{keep_nonce, nonce_refused, proof, token_refused}; let expiring = || self.tokens.lock().unwrap().get("expiresAt").and_then(Value::as_u64).is_some_and(|at| at < crate::now_ms() + 60_000); if expiring() { self.renew(&self.access()).await?; } let (mut renewed, mut nonced) = (false, false); loop { let access = self.access(); let key = self.tokens.lock().unwrap().get("dpopKey").and_then(Value::as_str).map(atproto_identity::key::identify_key); let key = key.ok_or("the OAuth session has no DPoP key")?.map_err(|e| e.to_string())?; let url = xrpc_url(&self.pds(), nsid, params)?; let method = if body.is_some() { "POST" } else { "GET" }; let mut all = headers.clone(); all.push(("authorization".into(), format!("DPoP {access}"))); all.push(("dpop".into(), proof(&key, method, &url, Some(&access))?)); if let Some((_, content_type)) = &body { all.push(("content-type".into(), content_type.to_string())); } let response = self.fetch.fetch(method, &url, all, body.as_ref().map(|(b, _)| b.clone())).await?; keep_nonce(&url, &response); if nonce_refused(&response) && !nonced { nonced = true; continue; } match json_response(nsid, &response) { Ok(body) => return Ok(body), Err(e) if !renewed && (token_refused(&response) || matches!(e.error.as_deref(), Some("ExpiredToken" | "InvalidToken" | "invalid_token"))) => { renewed = true; self.renew(&access).await? } Err(e) => return Err(e.message), } } } } /// hostCall: the run-module host contract. pub struct HostCall { pub fetch: Arc, pub session: Option>, /// Some(store) enables com.fedfork.exec.attest (needs a session and a module that lists calls). pub attest: Option, } impl Call for HostCall { fn call(&self, nsid: String, request: Request) -> BoxFut<'_, Result, String>> { Box::pin(async move { let Request { params, input, encoding, proxy, service, endpoints, .. } = request; if nsid == ATTEST_NSID { let (Some(store), Some(session)) = (&self.attest, &self.session) else { return Err( "attest needs a session and a module record that lists its calls".into(), ); }; return crypto::attest(store, Some(&session.did()), input.as_ref()).map(Some); } // Other host pseudo-calls (a transport this host was built without, ...) must not reach a PDS. if nsid.starts_with(transport::PSEUDO) { return Err(transport::unavailable(&nsid).unwrap_or_else(|| format!("{nsid} is not available on this host"))); } // did#id with its endpoints: an https one (TransportCall took any of its transports' schemes), // with service auth aud did#id if let (Some(endpoints), Some(aud)) = (&endpoints, &service) { let origin = endpoint_of(endpoints, "https").filter(|o| o.starts_with("https://")).ok_or_else(|| format!("{nsid}: this host reaches none of {aud}'s endpoints"))?; let session = self.session.as_ref().ok_or_else(|| format!("{nsid} needs a session (sign in: atlibs login + --as, or --app-password)"))?; let token = mint(session, aud, &nsid).await?; return bearer_call(self.fetch.as_ref(), &origin, &nsid, params.as_ref(), xrpc_body(input.as_ref(), encoding.as_deref())?, service_token(&token)?) .await .map(Some); } // A DID service (did: its PDS; did#id: that service) is a public GET, never with credentials; // so is a proxied query signed out, sent to the proxy's service directly. let open = service.as_deref().filter(|s| s.starts_with("did:")).or(proxy.as_deref().filter(|_| self.session.is_none() && input.is_none())); if let Some(did) = open { let pds = pds_of(self.fetch.as_ref(), did).await?; return get_json( self.fetch.as_ref(), &nsid, &xrpc_url(&pds, &nsid, params.as_ref())?, ) .await .map(Some); } let Some(session) = &self.session else { return Err(format!( "{nsid} needs a session (sign in: atlibs login + --as, or --app-password)" )); }; if let Some(service) = service { let auth_params: Map = [ ("aud".to_string(), json!(service_aud(&service))), ("lxm".to_string(), json!(nsid)), ] .into_iter() .collect(); let token = session .xrpc( "com.atproto.server.getServiceAuth", Some(&auth_params), None, vec![], ) .await?; return bearer_call( self.fetch.as_ref(), &service, &nsid, params.as_ref(), xrpc_body(input.as_ref(), encoding.as_deref())?, service_token(&token)?, ) .await .map(Some); } let headers = proxy .map(|p| vec![("atproto-proxy".to_string(), p)]) .unwrap_or_default(); session .xrpc_with(&nsid, params.as_ref(), xrpc_body(input.as_ref(), encoding.as_deref())?, headers) .await .map(Some) }) } // A subscribe opens wss:///xrpc/?: public // unless serviceAuth asks for the session's service-auth token (header or query parameter). fn subscribe(&self, nsid: String, mut request: Request) -> BoxFut<'_, Result> { Box::pin(async move { if nsid.starts_with(transport::PSEUDO) { return Err(transport::unavailable(&nsid).unwrap_or_else(|| format!("{nsid} cannot be subscribed to on this host"))); } if request.proxy.is_some() { return Err( "subscribe cannot go through atproto-proxy; call the service directly".into(), ); } let base = match request.service.as_deref() { Some(did) if did.starts_with("did:") => pds_of(self.fetch.as_ref(), did).await?, Some(service) => service.to_string(), None => self .session .as_ref() .map(|s| s.pds()) .ok_or_else(|| format!("{nsid} needs a service or a session"))?, }; if request.service_auth.is_some() { let session = self.session.as_ref().ok_or_else(|| { format!("{nsid} serviceAuth needs a session (sign in: atlibs login + --as, or --app-password)") })?; let service = request .service .clone() .filter(|s| s.starts_with("https://")) .ok_or_else(|| format!("{nsid} serviceAuth needs an https service"))?; let auth_params: Map = [ ("aud".to_string(), json!(service_aud(&service))), ("lxm".to_string(), json!(nsid)), ] .into_iter() .collect(); let token = session .xrpc("com.atproto.server.getServiceAuth", Some(&auth_params), None, vec![]) .await?; apply_service_auth(&mut request, service_token(&token)?); } let url = ws::ws_url(&base, &nsid, request.params.as_ref())?; Ok(ws::subscribe( url, nsid, request.framing, request.keepalive, request.headers, request.signal, )) }) } } /// Put a subscribe's service-auth token where its serviceAuth asks: { param } in that query /// parameter, {} as Authorization (hosts whose WebSocket takes headers). /// getServiceAuth's token: a non-empty string, never "undefined" sent as one (JS: "no service-auth token"). fn service_token(answer: &Value) -> Result<&str, String> { answer.get("token").and_then(Value::as_str).filter(|t| !t.is_empty()).ok_or_else(|| "getServiceAuth answered no service-auth token".into()) } pub fn apply_service_auth(request: &mut Request, token: &str) { let Some(auth) = request.service_auth.take() else { return; }; match auth.get("param").and_then(Value::as_str) { Some(param) => { request .params .get_or_insert_with(Map::new) .insert(param.to_string(), json!(token)); } None => request .headers .push(("authorization".into(), format!("Bearer {token}"))), } } pub(crate) async fn bearer_call( fetch: &dyn Fetch, service: &str, nsid: &str, params: Option<&Map>, body: Option<(Vec, String)>, token: &str, ) -> Result { let mut headers = vec![("authorization".to_string(), format!("Bearer {token}"))]; if let Some((_, content_type)) = &body { headers.push(("content-type".into(), content_type.clone())); } let response = fetch .fetch( if body.is_some() { "POST" } else { "GET" }, &xrpc_url(service, nsid, params)?, headers, body.map(|(b, _)| b), ) .await?; json_response(nsid, &response).map_err(|e| e.message) } /// The address of the first endpoint of `kind` ([{ kind, address }]). pub fn endpoint_of(endpoints: &Value, kind: &str) -> Option { endpoints.as_array()?.iter().find(|e| e["kind"] == kind).and_then(|e| e["address"].as_str()).map(String::from) } /// getServiceAuth { aud, lxm } with the session. pub async fn mint(session: &Session, aud: &str, lxm: &str) -> Result { let params: Map = [("aud".to_string(), json!(aud)), ("lxm".to_string(), json!(lxm))].into_iter().collect(); session.xrpc("com.atproto.server.getServiceAuth", Some(¶ms), None, vec![]).await } /// JS serviceAud: the did:web aud of a service-auth token for an https service URL, /// did:web:[%3A]. pub fn service_aud(url: &str) -> String { let parsed = reqwest::Url::parse(url).ok(); let host = parsed.as_ref().and_then(|u| u.host_str()).unwrap_or_default(); match parsed.as_ref().and_then(|u| u.port()) { Some(port) => format!("did:web:{host}%3A{port}"), None => format!("did:web:{host}"), } } fn origin(url: &str) -> Option { reqwest::Url::parse(url) .ok() .map(|u| u.origin().ascii_serialization()) } /// didkeyCall: the com.fedfork.exec.didkey.* pseudo-calls, and request calls to an https service in /// `services` carrying a service-auth token the did:key mints; everything else goes to inner. pub struct DidkeyCall { pub key: DidKey, pub services: Vec, pub fetch: Arc, pub inner: Arc, /// One serving instance per did:key: the relay keeps only the newest registration of a DID, so two /// runs serving one identity would take it from each other on every reconnect. With a store, a /// subscribe to a listed service holds the state dir's lease "atlibs-didkey-serving:" while it /// is open (renewed; every process sharing the state dir, JS ones too) and is refused while another /// run holds it. pub store: Option, } const SERVING_LEASE: std::time::Duration = std::time::Duration::from_secs(30); /// `sub` with `lease` held, and renewed, until either side ends it. fn leased(sub: Sub, lease: crate::store::Lease) -> Sub { use tokio::sync::mpsc; let (out, messages) = mpsc::unbounded_channel(); let (commands, mut asked) = mpsc::unbounded_channel(); let Sub { messages: mut from, commands: to } = sub; tokio::spawn(async move { let mut renew = tokio::time::interval(SERVING_LEASE / 3); loop { tokio::select! { message = from.recv() => match message { Some(m) => if out.send(m).is_err() { break }, // nobody reads it any more None => return, // it ended: the lease drops }, command = asked.recv() => match command { Some(crate::run::SubCmd::Close) | None => break, Some(c) => drop(to.send(c)), }, _ = renew.tick() => { if !lease.renew(SERVING_LEASE).unwrap_or(false) { let _ = out.send(Err("the did:key serving lease was lost".into())); break; } } } } let _ = to.send(crate::run::SubCmd::Close); }); Sub { messages, commands } } impl Call for DidkeyCall { fn call(&self, nsid: String, request: Request) -> BoxFut<'_, Result, String>> { Box::pin(async move { if let Some(done) = self.key .pseudo_call(&nsid, request.params.as_ref(), request.input.as_ref()) { return done.map(Some); } let service = request .service .clone() .filter(|s| s.starts_with("https://")); if let Some(service) = service.filter(|s| { origin(s) .is_some_and(|o| self.services.iter().any(|x| origin(x).as_ref() == Some(&o))) }) { let token = self.key.service_auth(&service_aud(&service), &nsid, None); let call = bearer_call( self.fetch.as_ref(), &service, &nsid, request.params.as_ref(), xrpc_body(request.input.as_ref(), request.encoding.as_deref())?, &token, ); return tokio::select! { out = call => out.map(Some), reason = request.signal.aborted() => Err(reason) }; } self.inner.call(nsid, request).await }) } // A subscribe with serviceAuth to a listed service: the did:key mints the token, inner opens the socket. fn subscribe(&self, nsid: String, mut request: Request) -> BoxFut<'_, Result> { Box::pin(async move { let listed = request .service .clone() .filter(|s| s.starts_with("https://")) .filter(|s| { origin(s).is_some_and(|o| { self.services.iter().any(|x| origin(x).as_ref() == Some(&o)) }) }); if let (Some(service), true) = (listed, request.service_auth.is_some()) { let token = self.key.service_auth(&service_aud(&service), &nsid, None); apply_service_auth(&mut request, &token); if let Some(store) = &self.store { let lease = store.try_lock(&format!("atlibs-didkey-serving:{}", self.key.did), SERVING_LEASE)?.ok_or_else(|| { format!("{} is already serving in another run: one serving instance per did:key (the relay keeps only its newest registration)", self.key.did) })?; return Ok(leased(self.inner.subscribe(nsid, request).await?, lease)); } } self.inner.subscribe(nsid, request).await }) } } #[cfg(test)] mod tests { #[test] fn bytes_go_in_and_come_out_as_dollar_bytes() { let body = |input: Value, encoding: Option<&str>| xrpc_body(Some(&input), encoding).unwrap().unwrap(); assert_eq!(body(json!({ "$bytes": "AAEC/w" }), Some("application/x-git-packed-objects")), (vec![0, 1, 2, 255], "application/x-git-packed-objects".into())); assert_eq!(body(json!({ "$bytes": "AAEC/w==" }), None), (vec![0, 1, 2, 255], "application/octet-stream".into())); assert_eq!(body(json!({ "$bytes": "AA", "x": 1 }), None).1, "application/json"); let answer = |t: &str| Response { status: 200, headers: vec![("content-type".into(), t.into())], body: vec![0, 1, 2, 255] }; assert_eq!(json_response("x", &answer("application/octet-stream")).ok(), Some(json!({ "$bytes": "AAEC/w" }))); assert_eq!(json_response("x", &answer("text/plain")).ok(), Some(json!({})), "text is not bytes"); } #[test] fn json_response_reads_an_event_stream() { let body = "event: meta\ndata: {\"jobUri\":\"at://x\"}\n\n: comment\n\ndata: one\ndata: two\n\n"; let response = super::Response { status: 200, headers: vec![("content-type".into(), "text/event-stream; charset=utf-8".into())], body: body.into() }; let out = super::json_response("dev.cocore.inference.dispatch", &response).ok().unwrap(); assert_eq!(out, serde_json::json!({ "events": [{ "event": "meta", "data": { "jobUri": "at://x" } }, { "event": "message", "data": "one\ntwo" }] })); } use super::*; use std::collections::HashMap; type Seen = Arc, Option)>>>; /// A fake fetch: answers by URL substring, records every request. struct Fake(Vec<(&'static str, u16, Value)>, Seen); impl Fetch for Fake { fn fetch( &self, method: &str, url: &str, headers: Vec<(String, String)>, body: Option>, ) -> BoxFut<'_, Result> { self.1.lock().unwrap().push(( method.into(), url.into(), headers, body.map(|b| String::from_utf8(b).unwrap()), )); let (_, status, body) = self .0 .iter() .find(|(m, ..)| url.contains(m)) .cloned() .unwrap_or(("", 404, json!({ "error": "NotFound" }))); Box::pin(async move { Ok(Response { status, headers: vec![], body: body.to_string().into_bytes(), }) }) } } fn did_doc(pds: &str) -> Value { json!({ "service": [{ "id": "#atproto_pds", "serviceEndpoint": pds }] }) } #[tokio::test] async fn every_host_call_route() { let seen: Seen = Arc::default(); let fake = Arc::new(Fake( vec![ ( "createSession", 200, json!({ "did": "did:plc:me", "handle": "me.test", "accessJwt": "a1", "refreshJwt": "r1", "didDoc": did_doc("https://pds.me") }), ), ( "plc.directory/did:plc:other", 200, did_doc("https://pds.other"), ), ( "pds.other/xrpc/com.atproto.repo.describeRepo", 200, json!({ "handle": "other.test" }), ), ("getServiceAuth", 200, json!({ "token": "svc" })), ("svc.example/xrpc/a.b.c", 200, json!({ "ok": 1 })), ( "pds.me/xrpc/com.atproto.repo.getRecord", 200, json!({ "value": {} }), ), ( "api.example/.well-known/did.json", 200, json!({ "service": [{ "id": "#svc", "serviceEndpoint": "https://api.example" }] }), ), ("api.example/xrpc/q.r.s", 200, json!({ "open": 1 })), ( "pds.me/xrpc/x.y.z", 429, json!({ "error": "RateLimitExceeded" }), ), ], seen.clone(), )); let store = Store::open(&std::env::temp_dir().join(format!("atlibs-host-session-{}", crate::store::uuid())), "atlibs-server").unwrap(); let session = Session::login( "me.test", Some("pw".into()), "https://bsky.social", fake.clone(), Some(store.clone()), ) .await .unwrap(); assert_eq!( (session.did(), session.pds()), ("did:plc:me".into(), "https://pds.me".into()) ); assert_eq!( store.setting("session").unwrap().unwrap()["identifier"], "me.test" ); let call = HostCall { fetch: fake.clone(), session: Some(session), attest: None, }; let req = |params: Value, service: Option<&str>, proxy: Option<&str>| Request { params: params.as_object().cloned(), service: service.map(String::from), proxy: proxy.map(String::from), ..Default::default() }; let read = call .call( "com.atproto.repo.describeRepo".into(), req( json!({ "repo": "did:plc:other" }), Some("did:plc:other"), None, ), ) .await .unwrap(); assert_eq!(read, Some(json!({ "handle": "other.test" }))); assert!( seen.lock().unwrap().last().unwrap().2.is_empty(), "a DID read carries no token" ); assert_eq!( call.call( "a.b.c".into(), req(json!({}), Some("https://svc.example"), None) ) .await .unwrap(), Some(json!({ "ok": 1 })) ); let log = seen.lock().unwrap().clone(); assert!( log[log.len() - 2] .1 .contains("getServiceAuth?aud=did%3Aweb%3Asvc.example&lxm=a.b.c") ); assert_eq!( log.last().unwrap().2, vec![("authorization".to_string(), "Bearer svc".to_string())] ); // did#id with endpoints (no transport took one): its https endpoint, service auth aud did#id let endpoints = Request { endpoints: Some(json!([{ "kind": "iroh", "address": "endpointabc234" }, { "kind": "https", "address": "https://svc.example" }])), ..req(json!({}), Some("did:plc:bidder#pdr_temp_market"), None) }; assert_eq!(call.call("a.b.c".into(), endpoints).await.unwrap(), Some(json!({ "ok": 1 }))); let log = seen.lock().unwrap().clone(); assert!(log[log.len() - 2].1.contains("getServiceAuth?aud=did%3Aplc%3Abidder%23pdr_temp_market&lxm=a.b.c"), "{}", log[log.len() - 2].1); assert_eq!(log.last().unwrap().2, vec![("authorization".to_string(), "Bearer svc".to_string())]); let unreachable = Request { endpoints: Some(json!([{ "kind": "iroh", "address": "endpointabc234" }])), ..req(json!({}), Some("did:plc:bidder#pdr_temp_market"), None) }; assert_eq!(call.call("a.b.c".into(), unreachable).await.unwrap_err(), "a.b.c: this host reaches none of did:plc:bidder#pdr_temp_market's endpoints"); call.call( "com.atproto.repo.getRecord".into(), req( json!({ "repo": "x" }), None, Some("did:web:api.example#svc"), ), ) .await .unwrap(); let last = seen.lock().unwrap().last().unwrap().clone(); assert!( last.2 .contains(&("atproto-proxy".into(), "did:web:api.example#svc".into())) && last .2 .contains(&("authorization".into(), "Bearer a1".into())) ); assert_eq!( call.call("x.y.z".into(), req(json!({}), None, None)) .await .unwrap_err(), "x.y.z failed with RateLimitExceeded" ); assert_eq!( call.call(ATTEST_NSID.into(), Request::default()) .await .unwrap_err(), "attest needs a session and a module record that lists its calls" ); let signed_out = HostCall { fetch: fake, session: None, attest: None, }; assert_eq!( signed_out .call("x.y.z".into(), Request::default()) .await .unwrap_err(), "x.y.z needs a session (sign in: atlibs login + --as, or --app-password)" ); // Signed out, a proxied query (and any did#id service) goes straight to that service, no credentials. for request in [req(json!({ "a": 1 }), None, Some("did:web:api.example#svc")), req(json!({ "a": 1 }), Some("did:web:api.example#svc"), None)] { assert_eq!(signed_out.call("q.r.s".into(), request).await.unwrap(), Some(json!({ "open": 1 }))); let last = seen.lock().unwrap().last().unwrap().clone(); assert_eq!((last.1.as_str(), last.2.len()), ("https://api.example/xrpc/q.r.s?a=1", 0)); } let procedure = Request { input: Some(json!({})), ..req(json!({}), None, Some("did:web:api.example#svc")) }; assert!(signed_out.call("q.r.s".into(), procedure).await.unwrap_err().ends_with("needs a session (sign in: atlibs login + --as, or --app-password)")); assert_eq!( signed_out.call("q.r.s".into(), req(json!({}), Some("did:web:api.example#nope"), None)).await.unwrap_err(), "did:web:api.example has no #nope service" ); assert_eq!( signed_out .subscribe("com.fedfork.exec.attest".into(), Request::default()) .await .err() .unwrap(), "com.fedfork.exec.attest cannot be subscribed to on this host" ); assert_eq!( signed_out .subscribe("a.b.c".into(), Request::default()) .await .err() .unwrap(), "a.b.c needs a service or a session" ); } #[tokio::test] async fn expired_tokens_refresh_once_and_saved_sessions_skip_create() { struct Expiring(Mutex, Seen); impl Fetch for Expiring { fn fetch( &self, method: &str, url: &str, headers: Vec<(String, String)>, _: Option>, ) -> BoxFut<'_, Result> { self.1 .lock() .unwrap() .push((method.into(), url.into(), headers.clone(), None)); let mut n = self.0.lock().unwrap(); *n += 1; let (status, body) = if url.contains("refreshSession") { ( 200, json!({ "did": "did:plc:me", "accessJwt": "a2", "refreshJwt": "r2" }), ) } else if headers.iter().any(|(_, v)| v == "Bearer a1") { (400, json!({ "error": "ExpiredToken" })) } else { (200, json!({ "ok": true })) }; Box::pin(async move { Ok(Response { status, headers: vec![], body: body.to_string().into_bytes(), }) }) } } let seen: Seen = Arc::default(); let fetch = Arc::new(Expiring(Mutex::new(0), seen.clone())); let store = Store::open(&std::env::temp_dir().join(format!("atlibs-host-session-{}", crate::store::uuid())), "atlibs-server").unwrap(); store.set_setting("session", &json!({ "identifier": "me.test", "did": "did:plc:me", "accessJwt": "a1", "refreshJwt": "r1" })).unwrap(); let session = Session::login("me.test", None, "https://pds.me", fetch, Some(store)).await.unwrap(); assert_eq!( session.xrpc("a.b.c", None, None, vec![]).await.unwrap(), json!({ "ok": true }) ); let urls: Vec = seen.lock().unwrap().iter().map(|r| r.1.clone()).collect(); assert_eq!( urls, [ "https://pds.me/xrpc/a.b.c", "https://pds.me/xrpc/com.atproto.server.refreshSession", "https://pds.me/xrpc/a.b.c" ] ); } /// A PDS that takes each refresh token once (rotation), so a second refresh with r1 would fail and /// fall back to createSession; a1 is expired, and refused as invalid once rotated. #[derive(Default)] struct Rotating { refreshes: Mutex, creates: Mutex, } impl Fetch for Rotating { fn fetch(&self, _: &str, url: &str, headers: Vec<(String, String)>, _: Option>) -> BoxFut<'_, Result> { let bearer = headers.iter().find(|(k, _)| k == "authorization").map(|(_, v)| v.clone()).unwrap_or_default(); let (status, body) = if url.contains("refreshSession") { let mut n = self.refreshes.lock().unwrap(); *n += 1; if bearer == "Bearer r1" && *n == 1 { (200, json!({ "did": "did:plc:me", "accessJwt": "a2", "refreshJwt": "r2" })) } else { (400, json!({ "error": "ExpiredToken", "message": "refresh token already used" })) } } else if url.contains("createSession") { *self.creates.lock().unwrap() += 1; (200, json!({ "did": "did:plc:me", "accessJwt": "a9", "refreshJwt": "r9" })) } else if bearer == "Bearer a1" && *self.refreshes.lock().unwrap() > 0 { (401, json!({ "error": "InvalidToken" })) } else if bearer == "Bearer a1" { (400, json!({ "error": "ExpiredToken" })) } else { (200, json!({ "ok": true })) }; Box::pin(async move { tokio::time::sleep(std::time::Duration::from_millis(10)).await; // let runs overlap Ok(Response { status, headers: vec![], body: body.to_string().into_bytes() }) }) } } #[tokio::test] async fn concurrent_runs_share_one_refresh_in_process_and_across_processes() { let dir = std::env::temp_dir().join(format!("atlibs-host-session-{}", crate::store::uuid())); let saved = json!({ "identifier": "me.test", "did": "did:plc:me", "accessJwt": "a1", "refreshJwt": "r1" }); let open = || Store::open(&dir, "atlibs-server").unwrap(); // its own sqlite connection, as another process has open().set_setting("session", &saved).unwrap(); let login = |fetch: Arc| Session::login("me.test", Some("pw".into()), "https://pds.me", fetch, Some(open())); let ok = json!({ "ok": true }); // 8 runs on one session: one refresh, everyone retries with it let pds = Arc::new(Rotating::default()); let session = login(pds.clone()).await.unwrap(); let ran = futures_util::future::join_all((0..8).map(|_| session.xrpc("a.b.c", None, None, vec![]))).await; assert!(ran.iter().all(|r| r.as_ref() == Ok(&ok)), "{ran:?}"); assert_eq!((*pds.refreshes.lock().unwrap(), *pds.creates.lock().unwrap()), (1, 0)); // two "processes" (sessions on their own connections) with the same saved, expired session: one refresh open().set_setting("session", &saved).unwrap(); let pds = Arc::new(Rotating::default()); let (a, b) = (login(pds.clone()).await.unwrap(), login(pds.clone()).await.unwrap()); let ran = futures_util::future::join_all((0..8).map(|i| if i % 2 == 0 { a.xrpc("a.b.c", None, None, vec![]) } else { b.xrpc("a.b.c", None, None, vec![]) })).await; assert!(ran.iter().all(|r| r.as_ref() == Ok(&ok)), "{ran:?}"); assert_eq!((*pds.refreshes.lock().unwrap(), *pds.creates.lock().unwrap()), (1, 0)); // a run still holding the rotated-away token (401 InvalidToken) re-reads the saved session: no refresh open().set_setting("session", &saved).unwrap(); let pds = Arc::new(Rotating::default()); let (a, stale) = (login(pds.clone()).await.unwrap(), login(pds.clone()).await.unwrap()); assert_eq!(a.xrpc("a.b.c", None, None, vec![]).await, Ok(ok.clone())); assert_eq!(stale.xrpc("a.b.c", None, None, vec![]).await, Ok(ok.clone())); assert_eq!((*pds.refreshes.lock().unwrap(), *pds.creates.lock().unwrap()), (1, 0)); assert_eq!(open().setting("session").unwrap().unwrap()["refreshJwt"], "r2"); // an empty state dir: concurrent first logins create one session let empty = std::env::temp_dir().join(format!("atlibs-host-session-{}", crate::store::uuid())); let pds = Arc::new(Rotating::default()); let first = |pds: Arc| Session::login("me.test", Some("pw".into()), "https://pds.me", pds, Some(Store::open(&empty, "atlibs-server").unwrap())); let (x, y) = futures_util::future::join(first(pds.clone()), first(pds.clone())).await; assert!(x.is_ok() && y.is_ok()); assert_eq!(*pds.creates.lock().unwrap(), 1); let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&empty); } /// An authorization server + PDS for OAuth sessions: refresh tokens used once (rotating), DPoP nonces /// required (use_dpop_nonce first), access tokens per DID (a token used for another repo is refused). #[derive(Default)] struct OAuthPds { refreshes: Mutex, writes: Mutex>, // (token's DID, repo written) } impl Fetch for OAuthPds { fn fetch(&self, _: &str, url: &str, headers: Vec<(String, String)>, body: Option>) -> BoxFut<'_, Result> { let header = |n: &str| headers.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone()).unwrap_or_default(); let proof: Value = header("dpop").split('.').nth(1).and_then(|p| crate::store::unbase64(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let nonce = vec![("dpop-nonce".to_string(), "n1".to_string())]; let body = String::from_utf8(body.unwrap_or_default()).unwrap_or_default(); let (status, extra, out) = if proof["nonce"] != "n1" { if url.contains("/token") { (400, vec![], json!({ "error": "use_dpop_nonce" })) } else { (401, vec![("www-authenticate".to_string(), "DPoP error=\"use_dpop_nonce\"".to_string())], json!({ "error": "use_dpop_nonce" })) } } else if url.contains("/token") { let form: HashMap = url::form_urlencoded::parse(body.as_bytes()).into_owned().collect(); let did = form["refresh_token"].split('|').next().unwrap_or_default().to_string(); let mut n = self.refreshes.lock().unwrap(); *n += 1; if form["refresh_token"].ends_with("|r1") && form.get("client_id").is_some_and(|c| c.starts_with("http://localhost?")) { (200, vec![], json!({ "access_token": format!("{did}|a2"), "refresh_token": format!("{did}|r2"), "token_type": "DPoP", "expires_in": 3600, "sub": did, "scope": "atproto" })) } else { (400, vec![], json!({ "error": "invalid_grant" })) } } else { let token = header("authorization").strip_prefix("DPoP ").unwrap_or_default().to_string(); let (did, which) = token.split_once('|').unwrap_or_default(); let repo = serde_json::from_str::(&body).ok().and_then(|b| b["repo"].as_str().map(String::from)).unwrap_or_default(); if which != "a2" || proof["ath"].as_str().is_none() { (401, vec![("www-authenticate".to_string(), "DPoP error=\"invalid_token\"".to_string())], json!({ "error": "invalid_token" })) } else if repo != did { (403, vec![], json!({ "error": "Forbidden", "message": "not your repo" })) } else { self.writes.lock().unwrap().push((did.to_string(), repo)); (200, vec![], json!({ "uri": format!("at://{did}/x/1") })) } }; Box::pin(async move { tokio::time::sleep(std::time::Duration::from_millis(10)).await; Ok(Response { status, headers: [nonce, extra].concat(), body: out.to_string().into_bytes() }) }) } } #[tokio::test] async fn oauth_sessions_refresh_once_per_user_and_never_cross() { let dir = std::env::temp_dir().join(format!("atlibs-host-oauth-{}", crate::store::uuid())); let open = || Store::open(&dir, "atlibs-server").unwrap(); let save = |did: &str| { let key = atproto_identity::key::generate_key(atproto_identity::key::KeyType::P256Private).unwrap(); let saved = json!({ "did": did, "pds": "https://pds.test", "issuer": "https://pds.test", "tokenEndpoint": "https://pds.test/oauth/token", "dpopKey": key.to_string(), "clientId": "http://localhost?scope=atproto", "authMethod": "none", "accessJwt": format!("{did}|a1"), "refreshJwt": format!("{did}|r1"), "expiresAt": crate::now_ms() + 1000 }); open().set_setting(&crate::oauth_client::setting(did), &saved).unwrap(); }; let write = |s: &Arc, repo: &str| { let (s, input) = (s.clone(), json!({ "repo": repo })); async move { s.xrpc("com.atproto.repo.createRecord", None, Some(&input), vec![]).await } }; assert!(Session::oauth("did:plc:nobody", Arc::new(OAuthPds::default()), open()).await.is_err(), "no saved session, no session"); // N=8 runs for one user, its token expiring, across two "processes" (sessions on their own connections): one refresh save("did:plc:alice"); let pds = Arc::new(OAuthPds::default()); let (a, b) = (Session::oauth("did:plc:alice", pds.clone(), open()).await.unwrap(), Session::oauth("did:plc:alice", pds.clone(), open()).await.unwrap()); assert!(a.is_oauth() && a.did() == "did:plc:alice" && a.pds() == "https://pds.test"); let ran = futures_util::future::join_all((0..8).map(|i| write(if i % 2 == 0 { &a } else { &b }, "did:plc:alice"))).await; assert!(ran.iter().all(Result::is_ok), "{ran:?}"); assert_eq!(*pds.refreshes.lock().unwrap(), 1); assert_eq!(open().setting("oauth:did:plc:alice").unwrap().unwrap()["refreshJwt"], "did:plc:alice|r2"); // 3 users x 3 runs at once: one refresh each, every write in the user's own repo with their own token let pds = Arc::new(OAuthPds::default()); let mut runs = vec![]; for user in ["did:plc:u1", "did:plc:u2", "did:plc:u3"] { save(user); let s = Session::oauth(user, pds.clone(), open()).await.unwrap(); runs.extend((0..3).map(|_| write(&s, user))); } let ran = futures_util::future::join_all(runs).await; assert!(ran.iter().all(Result::is_ok), "{ran:?}"); assert_eq!(*pds.refreshes.lock().unwrap(), 3); assert!(pds.writes.lock().unwrap().iter().all(|(did, repo)| did == repo)); // a user's session cannot write another's repo (the PDS refuses: the token is theirs alone) let u1 = Session::oauth("did:plc:u1", pds.clone(), open()).await.unwrap(); assert!(write(&u1, "did:plc:u2").await.unwrap_err().contains("not your repo")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn blob_cids() { let cid = format!( "b{}", crypto::base32(&[&[0x01, 0x55, 0x12, 0x20][..], &[7; 32]].concat()) ); assert_eq!(blob_sha256(Some(&cid)).unwrap(), "07".repeat(32)); assert_eq!( blob_sha256(Some( "bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" )) .unwrap_err(), "Blob CID must be a raw sha2-256 CIDv1" ); } #[tokio::test] async fn pds_of_is_https_off_the_host_network() { let resolve = |endpoint: &str| { let fake = Fake( vec![("plc.directory/did:plc:a", 200, did_doc(endpoint))], Arc::default(), ); async move { pds_of(&fake, "did:plc:a").await } }; assert_eq!(resolve("https://pds.example").await.unwrap(), "https://pds.example"); for bad in [ "http://pds.example", "https://127.0.0.1", "https://localhost:3000", "https://169.254.169.254/latest", "https://10.1.2.3", "https://172.20.0.1", "https://192.168.1.1", "https://100.64.0.1", "https://0.0.0.0", "https://[::1]", "https://[fd00::1]", "https://[fe80::1]", "https://[::ffff:127.0.0.1]", "https://2130706433", "file:///etc/passwd", "nope", ] { assert!(resolve(bad).await.is_err(), "{bad}"); } let seen: Seen = Arc::default(); let fake = Fake(vec![], seen.clone()); for did in ["did:web:localhost%3A8080", "did:web:127.0.0.1", "did:web:[::1]"] { assert!(pds_of(&fake, did).await.is_err(), "{did}"); } assert!(seen.lock().unwrap().is_empty(), "nothing private was fetched"); assert_eq!(public_url("http://127.0.0.1:2583", true).unwrap(), "http://127.0.0.1:2583"); } #[tokio::test] async fn load_module_carries_didkey_and_refuses_odd_records() { const URI: &str = "at://did:plc:pub/com.fedfork.exec.wasm.module/m"; let cid = "bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm"; let load = |record: Value| { let fake = Fake( vec![ ("plc.directory/did:plc:pub", 200, did_doc("https://pds.example")), ("getRecord", 200, record), ("getBlob", 200, json!("wasm")), ], Arc::default(), ); async move { load_module(&fake, URI).await } }; let record = |extra: Value| { let mut value = json!({ "abi": "emdash-wasm-json-v1", "module": { "ref": { "$link": cid } }, "calls": ["a.b.c"] }); value.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); json!({ "value": value }) }; let module = load(record(json!({ "didkey": { "services": ["https://xrpc.fedproxy.com"] } }))) .await .unwrap(); assert_eq!(module.id.as_deref(), Some(URI)); assert_eq!(module.didkey, Some(json!({ "services": ["https://xrpc.fedproxy.com"] }))); assert_eq!( load(record(json!({ "calls": "a.b.c" }))).await.err().unwrap(), "module calls must be a list of strings" ); assert_eq!( load(record(json!({ "anyRecords": [1] }))).await.err().unwrap(), "module anyRecords must be a list of strings" ); // a reply that is not an object (or has no value) is an error, not a panic for odd in [json!([1, 2]), json!("x"), json!(null), json!({})] { assert!(load(odd).await.err().unwrap().starts_with("Unsupported module ABI")); } } #[test] fn service_aud_encodes_the_port() { assert_eq!(service_aud("https://svc.example/x"), "did:web:svc.example"); assert_eq!(service_aud("https://svc.example:443/"), "did:web:svc.example"); assert_eq!(service_aud("https://svc.example:8443/xrpc"), "did:web:svc.example%3A8443"); } #[test] fn a_missing_service_auth_token_is_refused_not_sent() { assert_eq!(service_token(&json!({ "token": "jwt" })).unwrap(), "jwt"); for answer in [json!({}), json!({ "token": "" }), json!({ "token": null }), json!({ "token": 5 })] { assert_eq!(service_token(&answer).unwrap_err(), "getServiceAuth answered no service-auth token", "{answer}"); } } #[test] fn service_auth_goes_in_the_param_it_names_or_the_authorization_header() { let mut query = Request { params: json!({ "did": "d" }).as_object().cloned(), service_auth: Some(json!({ "param": "service_auth" })), ..Default::default() }; apply_service_auth(&mut query, "jwt1"); assert_eq!(Value::Object(query.params.unwrap()), json!({ "did": "d", "service_auth": "jwt1" })); assert!(query.service_auth.is_none() && query.headers.is_empty()); let mut header = Request { service_auth: Some(json!({})), ..Default::default() }; apply_service_auth(&mut header, "jwt1"); assert_eq!(header.headers, vec![("authorization".to_string(), "Bearer jwt1".to_string())]); let mut none = Request::default(); apply_service_auth(&mut none, "jwt1"); assert!(none.headers.is_empty() && none.params.is_none()); } #[tokio::test] async fn didkey_call_signs_a_subscribe_service_auth_for_listed_services_only() { struct Record(Mutex>); impl Call for Record { fn call(&self, _: String, _: Request) -> BoxFut<'_, Result, String>> { Box::pin(async { Ok(None) }) } fn subscribe(&self, _: String, request: Request) -> BoxFut<'_, Result> { self.0.lock().unwrap().push(request); Box::pin(async { Err("recorded".into()) }) } } struct NoFetch; impl Fetch for NoFetch { fn fetch( &self, _: &str, _: &str, _: Vec<(String, String)>, _: Option>, ) -> BoxFut<'_, Result> { Box::pin(async { Err("no network".into()) }) } } let inner = Arc::new(Record(Mutex::new(vec![]))); let key = DidKey::new(None).unwrap(); let did = key.did.clone(); let call = DidkeyCall { key, services: vec!["https://xrpc.fedproxy.com".into()], fetch: Arc::new(NoFetch), inner: inner.clone(), store: None }; let req = |service: &str, auth: Value| Request { service: Some(service.into()), subscribe: true, service_auth: Some(auth), ..Default::default() }; let nsid = "com.fedproxy.temp.xrpc.subscribe"; for r in [req("https://xrpc.fedproxy.com", json!({ "param": "service_auth" })), req("https://xrpc.fedproxy.com", json!({})), req("https://other.example", json!({}))] { let _ = call.subscribe(nsid.into(), r).await; } let seen = inner.0.lock().unwrap(); let token = seen[0].params.as_ref().unwrap()["service_auth"].as_str().unwrap().to_string(); let claims: Value = serde_json::from_slice(&base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, token.split('.').nth(1).unwrap()).unwrap()).unwrap(); assert_eq!((claims["iss"].as_str(), claims["aud"].as_str(), claims["lxm"].as_str()), (Some(did.as_str()), Some("did:web:xrpc.fedproxy.com"), Some(nsid))); assert!(seen[0].service_auth.is_none()); assert!(seen[1].headers[0].1.starts_with("Bearer ey")); assert_eq!(seen[2].service_auth, Some(json!({})), "not listed: left for the host's session token"); } /// Two "processes" (stores on their own connections to one state dir) serving one did:key: the /// second relay subscribe is refused while the first is open, and goes through once it closes. #[tokio::test] async fn one_serving_instance_per_did_key_across_processes() { struct Open; impl Call for Open { fn call(&self, _: String, _: Request) -> BoxFut<'_, Result, String>> { Box::pin(async { Ok(None) }) } fn subscribe(&self, _: String, _: Request) -> BoxFut<'_, Result> { let (tx, messages) = tokio::sync::mpsc::unbounded_channel(); let (commands, mut rx) = tokio::sync::mpsc::unbounded_channel(); tokio::spawn(async move { let _ = tx.send(Ok(json!("registered"))); while let Some(c) = rx.recv().await { if matches!(c, crate::run::SubCmd::Close) { break; // closing drops tx: the stream ends } } }); Box::pin(async move { Ok(Sub { messages, commands }) }) } } let dir = std::env::temp_dir().join(format!("atlibs-host-serving-{}", crate::store::uuid())); let key = DidKey::new(None).unwrap(); let serving = |store: Store| DidkeyCall { key: DidKey::new(Some(&key.jwk)).unwrap(), services: vec!["https://relay.example".into()], fetch: Arc::new(NoNet), inner: Arc::new(Open), store: Some(store) }; let (a, b) = (serving(Store::open(&dir, "atlibs-server").unwrap()), serving(Store::open(&dir, "atlibs-server").unwrap())); let relay = || Request { service: Some("https://relay.example".into()), subscribe: true, service_auth: Some(json!({})), ..Default::default() }; let nsid = "com.fedproxy.temp.xrpc.subscribe".to_string(); let mut first = a.subscribe(nsid.clone(), relay()).await.unwrap(); assert_eq!(first.messages.recv().await, Some(Ok(json!("registered")))); let refused = b.subscribe(nsid.clone(), relay()).await.err().unwrap(); assert!(refused.contains("already serving in another run"), "{refused}"); // not the relay: no lease needed assert!(b.subscribe(nsid.clone(), Request { service: Some("https://other.example".into()), subscribe: true, ..Default::default() }).await.is_ok()); first.commands.send(crate::run::SubCmd::Close).unwrap(); assert_eq!(first.messages.recv().await, None, "closed"); let mut second = b.subscribe(nsid, relay()).await.unwrap(); assert_eq!(second.messages.recv().await, Some(Ok(json!("registered")))); let _ = std::fs::remove_dir_all(&dir); } struct NoNet; impl Fetch for NoNet { fn fetch(&self, _: &str, _: &str, _: Vec<(String, String)>, _: Option>) -> BoxFut<'_, Result> { Box::pin(async { Err("no network".into()) }) } } }