//! OAuth client metadata (the Rust twin of javascript/src/oauth) and the handler an existing server //! mounts to serve it dynamically, as the emdash atproto-integrations plugin serves //! /integrations///oauth-client-metadata.json from com.fedfork.exec.oauth.clientMetadata //! records. //! //! ```ignore //! let handler = ClientMetadataHandler::from_stage(&stage_json); // `atlibs stage --target server` output //! // hyper: if let Some(res) = handler.handle(&req) { return Ok(res) } //! // axum: .route("/oauth/{session}/client-metadata.json", get(move |req: Request| async move { handler.handle(&req) })) //! ``` use std::sync::Arc; use serde_json::{Map, Value, json}; use crate::BoxFut; use crate::host::service_aud; use crate::run::{Call, Request, Sub}; pub const BASE_SCOPE: &str = "atproto"; pub const COLLECTION: &str = "com.fedfork.exec.oauth.clientMetadata"; /// The origin `atlibs stage --target spa` writes when no --origin is given: one literal to replace /// with the deployed origin (no trailing slash) in stage.json and the client documents. pub const TEMPLATE_ORIGIN: &str = "https://atlibs.invalid"; const REBASED: [&str; 4] = ["client_uri", "logo_uri", "tos_uri", "policy_uri"]; const LOOPBACK: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"]; pub fn scope_list(scope: &str) -> Vec<&str> { scope.split(' ').filter(|s| !s.is_empty()).collect() } fn origin_of(url: &str) -> Result { let parsed = url::Url::parse(url).map_err(|_| format!("{url} is not a URL"))?; Ok(parsed.origin().ascii_serialization()) } /// The client metadata document served at `client_id` for a record (JS clientMetadata): URLs on /// the template's origin move to the site's origin; always public and DPoP-bound. pub fn client_metadata(record: &Value, client_id: &str, redirect_uri: &str) -> Result { let meta = &record["clientMetadata"]; let scope = meta["scope"].as_str().unwrap_or_default(); let name = record["name"].as_str().unwrap_or_default(); if !scope_list(scope).contains(&BASE_SCOPE) { return Err(format!("{name} scope must include {BASE_SCOPE}")); } let template = origin_of(meta["client_id"].as_str().unwrap_or_default())?; let site = origin_of(client_id)?; let mut doc = Map::new(); doc.insert("client_name".into(), meta.get("client_name").cloned().unwrap_or_else(|| json!(name))); for field in REBASED { if let Some(rest) = meta[field].as_str().and_then(|v| v.strip_prefix(&template)) { doc.insert(field.into(), json!(format!("{site}{rest}"))); } } doc.extend( json!({ "client_id": client_id, "redirect_uris": [redirect_uri], "scope": scope, "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "application_type": "web", "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": true, }) .as_object() .cloned() .unwrap_or_default(), ); Ok(Value::Object(doc)) } /// Records composed into one record of the same lexicon (JS composeRecord): the union of their /// scopes, their scope groups keyed `.`. pub fn compose_record(records: &Map, name: &str, client_id: &str, redirect_uri: &str, created_at: &str) -> Result { let mut scopes = vec![BASE_SCOPE.to_string()]; for record in records.values() { for s in scope_list(record["clientMetadata"]["scope"].as_str().unwrap_or_default()) { if !scopes.iter().any(|x| x == s) { scopes.push(s.into()); } } } let names: Vec<&str> = records.values().filter_map(|r| r["name"].as_str()).collect(); let groups: Vec = records .iter() .flat_map(|(session, record)| { record["scopeGroups"].as_array().cloned().unwrap_or_default().into_iter().map(move |g| { let mut group = json!({ "key": format!("{session}.{}", g["key"].as_str().unwrap_or_default()), "label": g["label"], "scopes": g["scopes"] }); if g["optional"] == true { group["optional"] = json!(true); } group }) }) .collect(); Ok(json!({ "$type": COLLECTION, "name": name, "description": format!("{} in one sign-in.", names.join(", ")), "clientMetadata": { "client_id": client_id, "client_name": name, "client_uri": origin_of(client_id)?, "redirect_uris": [redirect_uri], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": scopes.join(" "), "application_type": "web", "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": true, }, "scopeGroups": groups, "createdAt": created_at, })) } /// Serves `/client-metadata.json` for each record, the client_id being that URL on /// `origin` (on a loopback Host, the request's origin when `origin` is unset). With `name`, /// `/client-metadata.json` is the composite of all of them. #[derive(Clone, Debug, Default)] pub struct ClientMetadataHandler { /// session (record key) -> com.fedfork.exec.oauth.clientMetadata record pub records: Map, /// URL path prefix, default "/oauth/" pub path: Option, /// the public origin (https://site); unset: only loopback Hosts are answered (http://) pub origin: Option, /// default: the origin + "/" pub redirect_uri: Option, /// composite client name pub name: Option, } impl ClientMetadataHandler { /// From `atlibs stage --target server` stage.json or an atlibs.lock.json ({ sessions: { : /// { record } } }), or a plain { : record } map. pub fn from_stage(source: &Value) -> Self { let records = match source.get("sessions").and_then(Value::as_object) { Some(sessions) => sessions.iter().filter_map(|(k, s)| s.get("record").map(|r| (k.clone(), r.clone()))).collect(), None => source.as_object().cloned().unwrap_or_default(), }; Self { records, path: source.get("oauthPath").and_then(Value::as_str).map(String::from), ..Default::default() } } fn prefix(&self) -> String { let p = self.path.as_deref().unwrap_or("/oauth/"); let p = if p.starts_with('/') { p.to_string() } else { format!("/{p}") }; if p.ends_with('/') { p } else { format!("{p}/") } } /// The client_id for a session (or the composite name) at an origin. pub fn client_id(&self, origin: &str, session: &str) -> String { format!("{}{}{session}/client-metadata.json", origin.trim_end_matches('/'), self.prefix()) } /// The document for a session at an origin; None when there is no such session. pub fn document(&self, origin: &str, session: &str) -> Option> { let client_id = self.client_id(origin, session); let redirect = self.redirect_uri.clone().unwrap_or_else(|| format!("{}/", origin.trim_end_matches('/'))); if let Some(record) = self.records.get(session) { return Some(client_metadata(record, &client_id, &redirect)); } if self.name.as_deref() == Some(session) { return Some(compose_record(&self.records, session, &client_id, &redirect, "1970-01-01T00:00:00.000Z").and_then(|r| client_metadata(&r, &client_id, &redirect))); } None } /// The origin a request reached: `origin`, else `http://` when Host is loopback. A /// non-loopback Host (or X-Forwarded-*) is never trusted: a public deployment sets `origin`. pub fn request_origin(&self, req: &http::Request) -> Option { if let Some(o) = &self.origin { return Some(o.trim_end_matches('/').to_string()); } let host = req.headers().get("host").and_then(|v| v.to_str().ok()).or_else(|| req.uri().authority().map(|a| a.as_str()))?; let hostname = host.rsplit_once(':').filter(|(h, p)| !h.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).map_or(host, |(h, _)| h); LOOPBACK.contains(&hostname).then(|| format!("http://{host}")) } /// Answers GET/HEAD `/client-metadata.json`; None for any other request, so a /// server falls through to its own routes. pub fn handle(&self, req: &http::Request) -> Option> { let session = req.uri().path().strip_prefix(&self.prefix())?.strip_suffix("/client-metadata.json")?; if session.contains('/') || !matches!(*req.method(), http::Method::GET | http::Method::HEAD) { return None; } let respond = |status: u16, body: Value| { http::Response::builder() .status(status) .header("content-type", "application/json") .header("cache-control", "public, max-age=60") .body(body.to_string()) .ok() }; let Some(origin) = self.request_origin(req) else { return respond(500, json!({ "error": "InternalServerError", "message": "no origin configured: set origin (atlibs serve --origin / ATLIBS_ORIGIN); only loopback hosts are read from the request" })); }; match self.document(&origin, session) { Some(Ok(doc)) => respond(200, doc), Some(Err(e)) => respond(500, json!({ "error": "InternalServerError", "message": e })), None => respond(404, json!({ "error": "NOT_FOUND" })), } } } /// What a call needs from a session (JS permissions): a repo collection + action, an rpc lxm + aud, or blobs. #[derive(Debug, PartialEq)] pub enum Need { /// collection (None: missing, granted by transition:generic only), action (from an applyWrites $type) Repo(Option, Option), Rpc(String, String), Blob, } const ACTIONS: [&str; 3] = ["create", "update", "delete"]; /// None: the call needs no scope (DID-service and public reads, host pseudo-calls, a subscribe with no /// serviceAuth); else every one of these (JS permissions). pub fn permissions(nsid: &str, request: &Request, subscribe: bool) -> Option> { let service = request.service.as_deref().filter(|s| !s.is_empty()); // did#id with endpoints: a service-auth call aud did#id (transport::TransportCall, host::HostCall) if let (Some(aud), Some(_)) = (service, &request.endpoints) { return Some(vec![Need::Rpc(nsid.into(), aud.into())]); } if service.is_some_and(|s| s.starts_with("did:")) || nsid.starts_with("com.fedfork.exec.") || (subscribe && request.service_auth.is_none()) { return None; } if let Some(proxy) = request.proxy.as_deref().filter(|p| !p.is_empty()) { return Some(vec![Need::Rpc(nsid.into(), proxy.into())]); } if let Some(service) = service { return Some(vec![Need::Rpc(nsid.into(), service_aud(service))]); } // a token for that lxm and aud: what the PDS checks an OAuth session for (an http allowlist's serviceAuth) if nsid == "com.atproto.server.getServiceAuth" { let param = |k: &str| request.params.as_ref().and_then(|p| p.get(k)).and_then(Value::as_str).unwrap_or_default().to_string(); return Some(vec![Need::Rpc(param("lxm"), param("aud"))]); } let collection = |v: &Value| v["collection"].as_str().filter(|c| !c.is_empty()).map(String::from); let input = request.input.as_ref().unwrap_or(&Value::Null); let write = |actions: &[&str]| Some(actions.iter().map(|a| Need::Repo(collection(input), Some(a.to_string()))).collect()); match nsid { "com.atproto.repo.createRecord" => write(&["create"]), "com.atproto.repo.putRecord" => write(&["create", "update"]), "com.atproto.repo.deleteRecord" => write(&["delete"]), "com.atproto.repo.applyWrites" => Some(input["writes"].as_array().into_iter().flatten().map(|w| Need::Repo(collection(w), w["$type"].as_str().and_then(|t| t.split('#').nth(1)).map(String::from))).collect()), "com.atproto.repo.uploadBlob" => Some(vec![Need::Blob]), _ if ["com.atproto.repo.getRecord", "com.atproto.repo.listRecords", "com.atproto.repo.describeRepo", "com.atproto.sync."].iter().any(|p| nsid.starts_with(p)) || nsid == "com.atproto.identity.resolveHandle" => None, _ => Some(vec![Need::Rpc(nsid.into(), "*".into())]), } } enum Granted { Repo(Vec, Vec), Rpc(Vec, Vec), Generic, Blob, Other, } fn parse_scope(item: &str) -> Granted { let (head, query) = item.split_once('?').unwrap_or((item, "")); let (resource, positional) = match head.split_once(':') { Some((r, p)) => (r, Some(p).filter(|p| !p.is_empty())), None => (head, None), }; let all = |key: &str| url::form_urlencoded::parse(query.as_bytes()).filter(|(k, _)| k == key).map(|(_, v)| v.into_owned()).collect::>(); let named = |key: &str| positional.map_or_else(|| all(key), |p| vec![p.to_string()]); match resource { "repo" => { let actions = all("action"); Granted::Repo(named("collection"), if actions.is_empty() { ACTIONS.map(String::from).to_vec() } else { actions }) } "rpc" => Granted::Rpc(named("lxm"), all("aud")), _ if item.starts_with("transition:generic") => Granted::Generic, "blob" => Granted::Blob, _ => Granted::Other, } } fn grants(scope: &Granted, need: &Need) -> bool { let any_of = |list: &[String], v: &str| list.iter().any(|x| x == "*" || x == v); match (scope, need) { (Granted::Generic, Need::Rpc(lxm, _)) => !lxm.starts_with("chat.bsky."), (Granted::Generic, _) => true, (Granted::Repo(collections, actions), Need::Repo(Some(c), Some(a))) => any_of(collections, c) && actions.contains(a), (Granted::Rpc(lxms, auds), Need::Rpc(lxm, aud)) => any_of(lxms, lxm) && (aud == "*" || any_of(auds, aud)), (Granted::Blob, Need::Blob) => true, _ => false, } } /// Whether a scope string lets a session make this call (JS scopeAllows). pub fn scope_allows(scope: &str, nsid: &str, request: &Request, subscribe: bool) -> bool { let Some(needs) = permissions(nsid, request, subscribe) else { return true }; let granted: Vec = scope_list(scope).into_iter().map(parse_scope).collect(); !needs.is_empty() && needs.iter().all(|n| granted.iter().any(|g| grants(g, n))) } /// A signed-in run's call held to its module's own session (JS server /// sessionCall, i.e. policyCall with the run's account as the one connected session): a call goes through when /// `session`'s scope allows it, or when it needs no scope; else it is refused with policyCall's text. pub struct ScopeCall { /// the module's key (its rkey, lockfile key, directory name or wasm file stem) pub module: String, /// its session record: (rkey, scope); None when it has none pub session: Option<(String, String)>, pub inner: Arc, } impl ScopeCall { fn check(&self, nsid: &str, request: &Request, subscribe: bool) -> Result<(), String> { let allowed = match &self.session { Some((_, scope)) => scope_allows(scope, nsid, request, subscribe), None => permissions(nsid, request, subscribe).is_none(), }; if allowed { return Ok(()); } let with = self.session.as_ref().map_or_else(|| "no connected session".to_string(), |(name, _)| format!("sessions {name}")); Err(format!("{} may not call {nsid} with {with}", self.module)) } } impl Call for ScopeCall { fn call(&self, nsid: String, request: Request) -> BoxFut<'_, Result, String>> { Box::pin(async move { self.check(&nsid, &request, request.subscribe)?; self.inner.call(nsid, request).await }) } fn subscribe(&self, nsid: String, request: Request) -> BoxFut<'_, Result> { Box::pin(async move { self.check(&nsid, &request, true)?; self.inner.subscribe(nsid, request).await }) } } /// JS appRepo: an app's own identity, did:web:, and the read-only repo its session records /// declare (`app`: { records: [{ collection, rkey, value }], wellKnown: { file: JSON } }), rebased from each /// record's template origin (its client_id's) to origin. The key is a P-256 did:key the host keeps. #[derive(Clone, Debug)] pub struct AppRepo { pub did: String, pub document: Value, /// (collection, rkey, uri, cid, value) pub records: Vec<(String, String, String, String, Value)>, pub well_known: Map, } const APP_READS: [&str; 3] = ["com.atproto.repo.getRecord", "com.atproto.repo.listRecords", "com.atproto.repo.describeRepo"]; impl AppRepo { /// None when no session record declares `app`. pub fn new(records: &Map, origin: &str, public_key_multibase: &str) -> Result, String> { if !records.values().any(|r| r.get("app").is_some()) { return Ok(None); } let site = url::Url::parse(origin).map_err(|_| format!("{origin} is not a URL"))?; let host = |u: &url::Url| format!("{}{}", u.host_str().unwrap_or_default(), u.port().map_or(String::new(), |p| format!("%3A{p}"))); if site.scheme() != "https" || LOOPBACK.contains(&site.host_str().unwrap_or_default()) { return Err(format!("an app identity needs a public https origin (a did:web resolves over https), not {origin}")); } let did = format!("did:web:{}", host(&site)); let site_origin = site.origin().ascii_serialization(); let mut out = Self { did: did.clone(), document: Value::Null, records: vec![], well_known: Map::new() }; for (session, record) in records { let template = url::Url::parse(record["clientMetadata"]["client_id"].as_str().unwrap_or_default()).map_err(|_| format!("{session}: client_id is not a URL"))?; let (template_origin, template_did) = (template.origin().ascii_serialization(), format!("did:web:{}", host(&template))); fn rebase(v: &Value, from: &str, to: &str, from_did: &str, did: &str) -> Value { match v { Value::String(s) if s == from_did => json!(did), Value::String(s) if s.starts_with(from) => json!(format!("{to}{}", &s[from.len()..])), Value::Array(a) => Value::Array(a.iter().map(|x| rebase(x, from, to, from_did, did)).collect()), Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), rebase(x, from, to, from_did, did))).collect()), other => other.clone(), } } let rebased = |v: &Value| rebase(v, &template_origin, &site_origin, &template_did, &did); for r in record["app"]["records"].as_array().into_iter().flatten() { let (collection, rkey) = (r["collection"].as_str().unwrap_or_default(), r["rkey"].as_str().unwrap_or_default()); let ok_collection = !collection.is_empty() && collection.chars().all(|c| c.is_ascii_alphanumeric() || ".-".contains(c)); let ok_rkey = (1..=512).contains(&rkey.len()) && rkey.chars().all(|c| c.is_ascii_alphanumeric() || "._:~-".contains(c)); if !ok_collection || !ok_rkey || !r["value"].is_object() { return Err(format!("{session}: an app record is {{ collection, rkey, value }}")); } let value = rebased(&r["value"]); if let Some(same) = out.records.iter().find(|x| x.0 == collection && x.1 == rkey) { if same.4 != value { return Err(format!("{session}: {collection}/{rkey} is declared twice, differently")); } continue; } let mut encoded = vec![]; crate::crypto::dag_cbor(&value, &mut encoded)?; use sha2::Digest; let cid = format!("b{}", crate::crypto::base32(&[&[0x01, 0x71, 0x12, 0x20][..], &sha2::Sha256::digest(&encoded)].concat())); out.records.push((collection.into(), rkey.into(), format!("at://{did}/{collection}/{rkey}"), cid, value)); } for (file, value) in record["app"]["wellKnown"].as_object().into_iter().flatten() { if file.is_empty() || !file.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) || file == "did.json" || file == "atproto-did" { return Err(format!("{session}: app wellKnown {file} is not a file name it may serve")); } out.well_known.insert(file.clone(), rebased(value)); } } out.document = json!({ "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], "id": did, "verificationMethod": [{ "id": format!("{did}#atproto"), "type": "Multikey", "controller": did, "publicKeyMultibase": public_key_multibase }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": site_origin }], }); Ok(Some(out)) } /// The host (with port) the DID names: requests to any other Host are not this repo's. pub fn host(&self) -> String { self.did["did:web:".len()..].replace("%3A", ":") } /// JS appRepoAnswer: (status, content-type, body) for the identity's well-known files and the repo reads; /// None for anything else. Read-only: no createAccount, getServiceAuth, writes or sync. pub fn answer(&self, path_and_query: &str) -> Option<(u16, &'static str, String)> { let url = url::Url::parse(&format!("http://app.invalid{path_and_query}")).ok()?; let json = |status: u16, body: Value| Some((status, "application/json", body.to_string())); let path = url.path(); if path == "/.well-known/did.json" { return json(200, self.document.clone()); } if path == "/.well-known/atproto-did" { return Some((200, "text/plain", self.did.clone())); } if let Some(value) = path.strip_prefix("/.well-known/").and_then(|f| self.well_known.get(f)) { return json(200, value.clone()); } let nsid = path.strip_prefix("/xrpc/").filter(|n| APP_READS.contains(n))?; let q = |k: &str| url.query_pairs().find(|(n, _)| n == k).map(|(_, v)| v.into_owned()).unwrap_or_default(); if q("repo") != self.did && q("repo") != self.host() { return json(400, json!({ "error": "RepoNotFound", "message": format!("this PDS holds only {}", self.did) })); } let out = |r: &(String, String, String, String, Value)| json!({ "uri": r.2, "cid": r.3, "value": r.4 }); match nsid { "com.atproto.repo.describeRepo" => { let mut collections: Vec<&str> = vec![]; for r in &self.records { if !collections.contains(&r.0.as_str()) { collections.push(&r.0); } } json(200, json!({ "handle": self.host(), "did": self.did, "didDoc": self.document, "collections": collections, "handleIsCorrect": true })) } "com.atproto.repo.listRecords" => json(200, json!({ "records": self.records.iter().filter(|r| r.0 == q("collection")).map(out).collect::>() })), _ => match self.records.iter().find(|r| r.0 == q("collection") && r.1 == q("rkey")) { Some(r) => json(200, out(r)), None => json(400, json!({ "error": "RecordNotFound", "message": format!("Could not locate record: at://{}/{}/{}", self.did, q("collection"), q("rkey")) })), }, } } /// answer() for a GET/HEAD whose Host is this repo's (public reads: CORS *); None otherwise. pub fn handle(&self, req: &http::Request) -> Option> { let host = req.headers().get("host").and_then(|v| v.to_str().ok())?; if host != self.host() || !matches!(*req.method(), http::Method::GET | http::Method::HEAD) { return None; } let (status, kind, body) = self.answer(req.uri().path_and_query().map_or("/", |p| p.as_str()))?; http::Response::builder().status(status).header("content-type", kind).header("access-control-allow-origin", "*").header("cache-control", "public, max-age=60").body(body).ok() } } #[cfg(test)] mod tests { use super::*; fn record(name: &str, scope: &str) -> Value { json!({ "$type": COLLECTION, "name": name, "clientMetadata": { "client_id": "https://tpl.example/c.json", "client_uri": "https://tpl.example/about", "scope": scope }, "scopeGroups": [{ "key": "k", "label": "K", "scopes": [scope] }], "createdAt": "x" }) } fn get(handler: &ClientMetadataHandler, host: &str, path: &str) -> Option<(u16, Value)> { let req = http::Request::get(path).header("host", host).body(()).unwrap(); handler.handle(&req).map(|r| (r.status().as_u16(), serde_json::from_str(r.body()).unwrap())) } #[test] fn serves_per_session_and_composite_metadata_on_the_configured_origin() { let stage = json!({ "sessions": { "atirc": { "record": record("atirc", "atproto rpc:a") }, "fedproxy": { "record": record("FedProxy", "atproto repo:b") } } }); let handler = ClientMetadataHandler { name: Some("ui".into()), ..ClientMetadataHandler::from_stage(&stage) }; assert_eq!(get(&handler, "site.example", "/oauth/atirc/client-metadata.json").unwrap().0, 500, "a public Host is never trusted"); let handler = ClientMetadataHandler { origin: Some("https://site.example".into()), ..handler }; let (status, doc) = get(&handler, "evil.example", "/oauth/atirc/client-metadata.json").unwrap(); assert_eq!(status, 200); assert_eq!(doc["client_id"], "https://site.example/oauth/atirc/client-metadata.json"); assert_eq!(doc["redirect_uris"], json!(["https://site.example/"])); assert_eq!(doc["client_uri"], "https://site.example/about"); assert_eq!(doc["scope"], "atproto rpc:a"); let (_, ui) = get(&ClientMetadataHandler { origin: None, ..handler.clone() }, "127.0.0.1:8787", "/oauth/ui/client-metadata.json").unwrap(); assert_eq!(ui["client_id"], "http://127.0.0.1:8787/oauth/ui/client-metadata.json"); assert_eq!(ui["scope"], "atproto rpc:a repo:b"); assert_eq!(get(&handler, "site.example", "/oauth/nope/client-metadata.json").unwrap().0, 404); assert!(get(&handler, "site.example", "/other").is_none()); assert!(get(&handler, "site.example", "/oauth/a/b/client-metadata.json").is_none()); let fixed = ClientMetadataHandler { origin: Some("https://fixed.example/".into()), path: Some("auth".into()), ..handler }; assert_eq!(get(&fixed, "x", "/auth/fedproxy/client-metadata.json").unwrap().1["client_id"], "https://fixed.example/auth/fedproxy/client-metadata.json"); } #[test] fn app_repo_serves_the_rebased_records_with_the_js_cids() { let registration = json!({ "$type": "dev.cocore.app.registration", "name": "App", "website": "https://app.example", "returnUrls": ["https://app.example/done"], "createdAt": "2026-11-10T00:00:00.000Z" }); let record = json!({ "clientMetadata": { "client_id": "https://app.example/oauth/app/client-metadata.json", "scope": "atproto" }, "app": { "records": [{ "collection": "dev.cocore.app.registration", "rkey": "self", "value": registration }], "wellKnown": { "cocore-app.json": { "did": "did:web:app.example" } } } }); let records = json!({ "app": record, "plain": { "clientMetadata": { "client_id": "https://p.example/c.json" } } }).as_object().cloned().unwrap(); let repo = AppRepo::new(&records, "https://deploy.example", "zDnaeKey").unwrap().unwrap(); assert_eq!(repo.did, "did:web:deploy.example"); // the same CID as javascript/src/oauth appRepo's test assert_eq!(repo.records[0].3, "bafyreiact3y3dp5yjllsptqtj57iv4kuzvo4rtxvpzakl7el5qgx6fp6nm"); assert_eq!(repo.records[0].4["returnUrls"], json!(["https://deploy.example/done"])); let body = |path: &str| serde_json::from_str::(&repo.answer(path).unwrap().2).unwrap(); assert_eq!(body("/.well-known/cocore-app.json"), json!({ "did": "did:web:deploy.example" })); assert_eq!(body("/.well-known/did.json")["service"][0]["serviceEndpoint"], "https://deploy.example"); assert_eq!(body("/xrpc/com.atproto.repo.getRecord?repo=did:web:deploy.example&collection=dev.cocore.app.registration&rkey=self")["value"]["website"], "https://deploy.example"); assert_eq!(body("/xrpc/com.atproto.repo.getRecord?repo=deploy.example&collection=dev.cocore.app.registration&rkey=nope")["error"], "RecordNotFound"); assert_eq!(body("/xrpc/com.atproto.repo.listRecords?repo=did:plc:x&collection=y")["error"], "RepoNotFound"); assert_eq!(repo.answer("/.well-known/atproto-did").unwrap(), (200, "text/plain", "did:web:deploy.example".to_string())); for path in ["/xrpc/com.atproto.server.getServiceAuth?aud=x", "/xrpc/com.atproto.server.createAccount", "/xrpc/com.atproto.sync.getRepo", "/"] { assert!(repo.answer(path).is_none(), "{path}"); } let get = |host: &str| repo.handle(&http::Request::get("/.well-known/did.json").header("host", host).body(()).unwrap()); assert_eq!(get("deploy.example").unwrap().headers()["access-control-allow-origin"], "*"); assert!(get("elsewhere.example").is_none()); assert!(AppRepo::new(&records, "http://127.0.0.1:8787", "z").unwrap_err().contains("public https origin")); assert!(AppRepo::new(&json!({ "plain": records["plain"] }).as_object().cloned().unwrap(), "https://deploy.example", "z").unwrap().is_none()); } #[test] fn scope_allows_maps_calls_to_repo_rpc_and_blob_permissions() { let req = |v: Value| Request { input: v.get("input").cloned(), proxy: v["proxy"].as_str().map(String::from), service: v["service"].as_str().map(String::from), service_auth: v.get("serviceAuth").cloned(), ..Default::default() }; let create = |c: &str| req(json!({ "input": { "collection": c } })); let scope = "atproto repo:a.b.c?action=create&action=delete rpc:x.y.z?aud=did:web:svc.example%23x blob:*/*"; assert!(scope_allows(scope, "com.atproto.repo.createRecord", &create("a.b.c"), false)); assert!(!scope_allows(scope, "com.atproto.repo.createRecord", &create("d.e.f"), false)); assert!(!scope_allows(scope, "com.atproto.repo.putRecord", &create("a.b.c"), false), "put needs create and update"); assert!(scope_allows("atproto repo:a.b.c", "com.atproto.repo.putRecord", &create("a.b.c"), false)); assert!(!scope_allows(scope, "com.atproto.repo.createRecord", &req(json!({ "input": {} })), false), "no collection"); assert!(scope_allows(scope, "x.y.z", &req(json!({ "proxy": "did:web:svc.example#x" })), false)); assert!(!scope_allows(scope, "x.y.z", &req(json!({ "proxy": "did:web:evil.example#x" })), false)); assert!(scope_allows("atproto rpc?lxm=x.y.z&aud=did:web:svc.example%253A8443", "x.y.z", &req(json!({ "service": "https://svc.example:8443" })), false)); // getServiceAuth needs rpc:?aud= (the PDS's rule for an OAuth session) let mint = |aud: &str, lxm: &str| Request { params: json!({ "aud": aud, "lxm": lxm }).as_object().cloned(), ..Default::default() }; let cocore = "atproto rpc:dev.cocore.inference.dispatch?aud=did:web:cocore.dev"; assert!(scope_allows(cocore, "com.atproto.server.getServiceAuth", &mint("did:web:cocore.dev", "dev.cocore.inference.dispatch"), false)); assert!(!scope_allows(cocore, "com.atproto.server.getServiceAuth", &mint("did:web:evil.example", "dev.cocore.inference.dispatch"), false)); assert!(!scope_allows(cocore, "com.atproto.server.getServiceAuth", &mint("did:web:cocore.dev", "x.y.z"), false)); assert!(!scope_allows("atproto rpc:com.atproto.server.getServiceAuth?aud=*", "com.atproto.server.getServiceAuth", &mint("did:web:cocore.dev", "x.y.z"), false)); assert!(scope_allows(scope, "com.atproto.repo.uploadBlob", &req(json!({ "input": {} })), false) && !scope_allows("atproto", "com.atproto.repo.uploadBlob", &req(json!({ "input": {} })), false)); let writes = req(json!({ "input": { "writes": [{ "$type": "com.atproto.repo.applyWrites#delete", "collection": "a.b.c" }] } })); assert!(scope_allows("atproto repo?collection=a.b.c&action=delete", "com.atproto.repo.applyWrites", &writes, false)); assert!(!scope_allows("atproto", "com.atproto.repo.applyWrites", &req(json!({ "input": { "writes": [] } })), false)); assert!(scope_allows("atproto", "com.atproto.repo.listRecords", &req(json!({})), false), "reads need no scope"); assert!(scope_allows("atproto", "com.fedfork.exec.attest", &req(json!({})), false), "host pseudo-calls need none"); assert!(scope_allows("atproto", "com.atproto.sync.subscribeRepos", &req(json!({ "service": "https://relay.example" })), true), "a public stream"); assert!(!scope_allows("atproto", "a.b.watch", &req(json!({ "service": "https://svc.example", "serviceAuth": {} })), true)); assert!(scope_allows("atproto transition:generic", "com.atproto.repo.createRecord", &create("any.thing"), false)); assert!(!scope_allows("atproto transition:generic", "chat.bsky.convo.sendMessage", &req(json!({ "input": {} })), false)); } #[test] fn refuses_records_without_atproto_scope() { assert!(client_metadata(&record("x", "repo:a"), "https://s/c", "https://s/").is_err()); } }