1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
//! 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/<did>/<rkey>/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<String, String> {
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<Value, String> {
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 `<session>.<key>`.
pub fn compose_record(records: &Map<String, Value>, name: &str, client_id: &str, redirect_uri: &str, created_at: &str) -> Result<Value, String> {
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<Value> = 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 `<path><session>/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`,
/// `<path><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<String, Value>,
/// URL path prefix, default "/oauth/"
pub path: Option<String>,
/// the public origin (https://site); unset: only loopback Hosts are answered (http://<Host>)
pub origin: Option<String>,
/// default: the origin + "/"
pub redirect_uri: Option<String>,
/// composite client name
pub name: Option<String>,
}
impl ClientMetadataHandler {
/// From `atlibs stage --target server` stage.json or an atlibs.lock.json ({ sessions: { <rkey>:
/// { record } } }), or a plain { <rkey>: 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<Result<Value, String>> {
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://<Host>` when Host is loopback. A
/// non-loopback Host (or X-Forwarded-*) is never trusted: a public deployment sets `origin`.
pub fn request_origin<B>(&self, req: &http::Request<B>) -> Option<String> {
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 `<path><session>/client-metadata.json`; None for any other request, so a
/// server falls through to its own routes.
pub fn handle<B>(&self, req: &http::Request<B>) -> Option<http::Response<String>> {
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<String>, Option<String>),
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<Vec<Need>> {
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<String>, Vec<String>),
Rpc(Vec<String>, Vec<String>),
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::<Vec<_>>();
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<Granted> = 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<dyn Call>,
}
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<Option<Value>, 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<Sub, String>> {
Box::pin(async move {
self.check(&nsid, &request, true)?;
self.inner.subscribe(nsid, request).await
})
}
}
/// JS appRepo: an app's own identity, did:web:<origin's host>, 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<String, Value>,
}
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<String, Value>, origin: &str, public_key_multibase: &str) -> Result<Option<Self>, 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::<Vec<_>>() })),
_ => 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<B>(&self, req: &http::Request<B>) -> Option<http::Response<String>> {
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::<Value>(&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:<lxm>?aud=<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());
}
}