~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Spaces renew their credential before it runs out

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:run/space-renew
.changeset/space-credential-renewal.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: patch
---
# A private repository read for longer than a space credential lasts goes on reading
A space credential lasts minutes. What reads a private repository for longer, as `knotless-run` watching one does, renews it before it runs out rather than failing every read after with `JwtExpired`, and a session read with renews the same way.
crates/knotless/src/account.rs+1−1
@@ -178,7 +178,7 @@ pub async fn inventory(ctx: &Ctx) -> Result<Inventory> {
};
for uri in &spaces {
let inside = Remote {
space: Some(Space::owner(uri, &me)),
space: Some(Space::owner(ctx, uri, &me)),
..remote.clone()
};
for r in inside.list_repos().await? {
crates/knotless/src/forge.rs+3−6
@@ -920,14 +920,11 @@ impl Target {
if let Some(c) = &cursor {
params.push(("cursor", c));
}
let auth = space.for_repo(self.remote.did()).await?;
let page: Repos = self
.remote
.xrpc
.query(
"com.atproto.space.listRepos",
&params,
Some(&space.for_repo(self.remote.did())),
)
.query("com.atproto.space.listRepos", &params, Some(&auth))
.await?;
let done = page.repos.is_empty();
dids.extend(page.repos.into_iter().map(|r| r.did));
@@ -981,7 +978,7 @@ impl Target {
.remote
.space
.as_ref()
.map(|s| Space::owner(&s.uri, &me)),
.map(|s| Space::owner(ctx, &s.uri, &me)),
};
Ok((mine, me))
}
crates/knotless/src/manage.rs+15−8
@@ -77,7 +77,7 @@ pub async fn create(ctx: &Ctx, url: &RepoUrl, new: &NewRepo) -> Result<Created>
// gateway.
Err(err) => return crate::org::create_repo(ctx, url, new, err).await,
};
vacant(&remote, &auth, url, new.private).await?;
vacant(ctx, &remote, &auth, url, new.private).await?;
// An organization's members push to each of its repositories, through
// its gateway, and read each private one.
let org = crate::org::roster_as(ctx, &auth).await?;
@@ -88,7 +88,7 @@ pub async fn create(ctx: &Ctx, url: &RepoUrl, new: &NewRepo) -> Result<Created>
session: &auth,
};
let (uri, _) = admin.create(&url.name).await?;
remote.space = Some(Space::owner(&uri, &auth));
remote.space = Some(Space::owner(ctx, &uri, &auth));
// Its first write, the record below, then reaches the appview,
// which has it in its index before anyone looks. Without, it finds
// it as its owner logs in there.
@@ -172,13 +172,19 @@ pub async fn create(ctx: &Ctx, url: &RepoUrl, new: &NewRepo) -> Result<Created>
/// since, which keeps its records in the space of its first name and points
/// to them from each later one. Members of that repository hold
/// credentials for the space's address, so it never changes hands.
async fn vacant(remote: &Remote, auth: &Session, url: &RepoUrl, private: bool) -> Result<()> {
async fn vacant(
ctx: &Ctx,
remote: &Remote,
auth: &Session,
url: &RepoUrl,
private: bool,
) -> Result<()> {
let public = Remote {
space: None,
..remote.clone()
};
let spaced = |uri: &str| Remote {
space: Some(Space::owner(uri, auth)),
space: Some(Space::owner(ctx, uri, auth)),
..public.clone()
};
let named = crate::space::uri(remote.did(), &url.name);
@@ -299,7 +305,7 @@ pub async fn list(ctx: &Ctx, owner: &str) -> Result<Vec<Listed>> {
// name; the spaces of later names hold only a redirect.
for uri in spaces {
let inside = Remote {
space: Some(Space::owner(&uri, &me)),
space: Some(Space::owner(ctx, &uri, &me)),
..remote.clone()
};
all.extend(
@@ -975,12 +981,12 @@ pub async fn rename(ctx: &Ctx, url: &RepoUrl, name: &str) -> Result<RepoUrl> {
if repo.rkey == to.name {
bail!("{url} is already named {}", to.name);
}
vacant(&remote, &auth, &to, false).await?;
vacant(ctx, &remote, &auth, &to, false).await?;
// A private repository's records stay in the space of its first name;
// a space under the new name points there, for members to find it by.
let pointer = match &remote.space {
Some(space) if space.uri != crate::space::uri(remote.did(), &to.name) => {
Some(point(&remote, &auth, &space.uri, &to, &repo.value.key).await?)
Some(point(ctx, &remote, &auth, &space.uri, &to, &repo.value.key).await?)
}
_ => None,
};
@@ -1022,6 +1028,7 @@ pub async fn rename(ctx: &Ctx, url: &RepoUrl, name: &str) -> Result<RepoUrl> {
/// it had from a time it had this name before. Returns where the redirect
/// goes and its write, for once the repository has the name.
async fn point(
ctx: &Ctx,
remote: &Remote,
auth: &Session,
home: &str,
@@ -1035,7 +1042,7 @@ async fn point(
let (uri, made) = admin.create(&to.name).await?;
let there = Remote {
url: to.clone(),
space: Some(Space::owner(&uri, auth)),
space: Some(Space::owner(ctx, &uri, auth)),
..remote.clone()
};
if !made && there.home().await?.as_deref() != Some(home) {
crates/knotless/src/org.rs+3−3
@@ -705,7 +705,7 @@ impl<'a> Own<'a> {
let spaces = self.admin().spaces().await.unwrap_or_default();
for uri in spaces {
let inner = Remote {
space: Some(Space::owner(&uri, self.session)),
space: Some(Space::owner(self.ctx, &uri, self.session)),
..self.open.clone()
};
for repo in inner.list_repos().await? {
@@ -1120,7 +1120,7 @@ pub async fn join(ctx: &Ctx, org: &str, visibility: Visibility) -> Result<Joined
}
let open = public(ctx, &mine);
let hidden = Remote {
space: Some(Space::owner(&space_uri(&org_id.did), &me)),
space: Some(Space::owner(ctx, &space_uri(&org_id.did), &me)),
..open.clone()
};
let (place, other) = match visibility {
@@ -1180,7 +1180,7 @@ pub async fn leave(ctx: &Ctx, org: &str) -> Result<(String, Option<String>)> {
let name = name.get(&org_id.did).cloned().unwrap_or(org_id.did.clone());
let open = public(ctx, &mine);
let hidden = Remote {
space: Some(Space::owner(&space_uri(&org_id.did), &me)),
space: Some(Space::owner(ctx, &space_uri(&org_id.did), &me)),
..open.clone()
};
let mut left = false;
crates/knotless/src/remote.rs+12−7
@@ -174,7 +174,7 @@ impl Remote {
// from a delegation token they handed over ([`Remote::delegated`]).
let Some(me) = me else {
let did = ctx.env.identifier.as_deref().unwrap_or_default();
return match Space::kept(ctx, &uri, public.did(), did) {
return match Space::kept(ctx, &public.xrpc, &uri, public.did(), did) {
Some(found) => Ok(Remote {
space: Some(found),
..public
@@ -183,7 +183,7 @@ impl Remote {
};
};
let found = if me.did == public.did() {
Space::owner(&uri, &me)
Space::owner(ctx, &uri, &me)
} else {
match Space::join(ctx, &public.xrpc, &uri, public.did(), &me).await {
Ok(found) => found,
@@ -203,7 +203,7 @@ impl Remote {
&& let Some(home) = private.home().await?
{
let space = if me.did == public.did() {
Space::owner(&home, &me)
Space::owner(ctx, &home, &me)
} else {
Space::join(ctx, &public.xrpc, &home, public.did(), &me).await?
};
@@ -273,10 +273,14 @@ impl Remote {
None => format!("com.atproto.repo.{method}"),
};
all.extend_from_slice(params);
let auth = match self.space.as_ref().map(|s| &s.auth) {
let auth = match &self.space {
Some(space) => Some(space.auth().await?),
None => None,
};
let auth = match auth {
// A credential's read of one record asks for its MAC too.
Some(auth) if method == "getRecord" => Some(space::want_mac(auth, space::RECORD_MAC)),
auth => auth.cloned(),
Some(auth) if method == "getRecord" => Some(space::want_mac(&auth, space::RECORD_MAC)),
auth => auth,
};
self.xrpc.query(&nsid, &all, auth.as_ref()).await
}
@@ -284,8 +288,9 @@ impl Remote {
pub async fn get_blob(&self, cid: &str) -> Result<Vec<u8>> {
match &self.space {
Some(space) => {
let auth = space.auth().await?;
self.xrpc
.get_space_blob(&space.uri, self.did(), cid, &space.auth)
.get_space_blob(&space.uri, self.did(), cid, &auth)
.await
}
None => self.xrpc.get_blob(self.did(), cid).await,
crates/knotless/src/session.rs+1−1
@@ -78,7 +78,7 @@ impl Session {
}
/// Whether the access token expires within a minute.
fn expiring(&self) -> bool {
pub(crate) fn expiring(&self) -> bool {
match &self.oauth {
Some(oauth) => oauth.expires_at < crate::keys::now_micros() / 1_000_000 + 60,
None => expiring(&self.access_jwt),
crates/knotless/src/space.rs+305−59
@@ -40,13 +40,78 @@ pub fn uri(did: &str, name: &str) -> String {
format!("at://{did}/space/{SPACE_TYPE}/{name}")
}
/// A space, and what lets requests read its owner's repo in it.
/// A space, and what lets requests read its owner's repo in it. Its clones
/// share what lets them in, renewed for all of them as it runs out: a
/// credential lasts minutes, and a runner reads a space for days.
#[derive(Clone)]
pub struct Space {
pub uri: String,
pub auth: Auth,
/// Whose repo in the space its requests read: the audience a
/// credential's name.
audience: String,
access: Arc<Access>,
}
/// What lets a space's requests in, and how to get it again.
struct Access {
held: tokio::sync::Mutex<Held>,
renew: Renew,
}
#[derive(Clone)]
enum Held {
/// The owner's own session, which reads only their repo there.
Session(Box<Session>),
/// A credential and its key, to read any member's repo in the space.
credential: Option<(String, Arc<P256Key>)>,
Credential(String, Arc<P256Key>),
}
impl Held {
/// Whether it ends within a minute, as a session or credential kept on
/// disk is renewed.
fn ending(&self) -> bool {
match self {
Held::Session(session) => session.expiring(),
Held::Credential(credential, _) => !lasts(credential),
}
}
}
/// What gets a space's reader in again: their session, kept under `ctx`,
/// and for a credential the PDS of the space's authority, `host`.
struct Renew {
ctx: Ctx,
host: Xrpc,
/// Whom the space is read as.
did: String,
}
impl Renew {
fn of(ctx: &Ctx, host: &Xrpc, did: &str) -> Renew {
Renew {
ctx: ctx.clone(),
host: host.clone(),
did: did.to_string(),
}
}
/// Another of what `held` is: the reader's session as it is now, or a
/// credential, kept on disk if another read renewed it already, else
/// exchanged afresh.
async fn renewed(&self, uri: &str, held: &Held) -> Result<Held> {
let session = matches!(held, Held::Session(_));
if !session && let Some(kept) = kept_credential(&self.ctx, uri, &self.did) {
return Ok(kept);
}
let id = identity::resolve(&self.ctx, &self.did).await?;
let me = session::ensure(&self.ctx, &id).await?;
if session {
return Ok(Held::Session(Box::new(me)));
}
let key = P256Key::generate();
let credential = credential(&self.ctx, &self.host, uri, &me, &key).await?;
Ok(store_credential(&self.ctx, uri, &self.did, credential, key))
}
}
#[derive(Serialize, Deserialize)]
@@ -105,48 +170,65 @@ pub fn scope_missing(err: &anyhow::Error) -> bool {
}
impl Space {
/// The owner reads their own repo in the space with their session.
pub fn owner(uri: &str, session: &Session) -> Space {
Space {
uri: uri.to_string(),
auth: session.auth(),
credential: None,
}
/// The owner reads their own repo in the space with their session,
/// renewed under `ctx` as it runs out.
pub fn owner(ctx: &Ctx, uri: &str, session: &Session) -> Space {
let held = Held::Session(Box::new(session.clone()));
let host = Xrpc::new(ctx.http.clone(), &session.service);
let renew = Renew::of(ctx, &host, &session.did);
Space::with(uri, &session.did, held, renew)
}
/// A credential holder reads `audience`'s repo in the space.
pub fn member(uri: &str, audience: &str, credential: &str, key: P256Key) -> Space {
fn with(uri: &str, audience: &str, held: Held, renew: Renew) -> Space {
Space {
uri: uri.to_string(),
auth: Auth::Headers(signed(
&key,
&format!("Atproto-Space-Credential {credential}"),
audience,
)),
credential: Some((credential.to_string(), Arc::new(key))),
audience: audience.to_string(),
access: Arc::new(Access {
held: tokio::sync::Mutex::new(held),
renew,
}),
}
}
/// The space as read from `did`'s repo in it.
pub fn at(&self, did: &str) -> Space {
Space {
auth: self.for_repo(did),
audience: did.to_string(),
..self.clone()
}
}
/// What lets a request read the repo this space is read from.
pub async fn auth(&self) -> Result<Auth> {
self.for_repo(&self.audience).await
}
/// What lets a request read `did`'s repo in the space, or ask the
/// authority (`did` the space's) which repos it holds. Only a
/// credential reaches past its holder's own repo.
pub fn for_repo(&self, did: &str) -> Auth {
match &self.credential {
Some((credential, key)) => Auth::Headers(signed(
key,
pub async fn for_repo(&self, did: &str) -> Result<Auth> {
Ok(match self.held().await? {
Held::Credential(credential, key) => Auth::Headers(signed(
&key,
&format!("Atproto-Space-Credential {credential}"),
did,
)),
None => self.auth.clone(),
Held::Session(session) => session.auth(),
})
}
/// What lets this space's requests in, renewed first if it ends within
/// a minute: one renewal for every clone reading at once.
async fn held(&self) -> Result<Held> {
let mut held = self.access.held.lock().await;
if held.ending() {
// Boxed: a renewal logs in and exchanges, a future far larger
// than the read it is part of, and every read of a space has
// this one in it.
let renewed = Box::pin(self.access.renew.renewed(&self.uri, &held));
*held = renewed.await?;
}
Ok(held.clone())
}
/// Where the space's writes are now, if any member's repo there moved
@@ -184,7 +266,7 @@ impl Space {
if let Some(c) = &cursor {
params.push(("cursor", c));
}
let auth = self.for_repo(authority);
let auth = self.for_repo(authority).await?;
let page: Repos = host
.query("com.atproto.space.listRepos", &params, Some(&auth))
.await?;
@@ -200,9 +282,6 @@ impl Space {
}
}
/// Joins `uri` as `me`, a member reading `authority`'s repo there:
/// a cached credential while it lasts, else a fresh one. `host` is the
/// authority's PDS.
/// Asks the space's authority to tell `service`, a DID and the entry in
/// its document to reach, of each write in the space from now on, as
/// `notifyWrite`: until the time it answers, unless asked again. Only
@@ -219,13 +298,16 @@ impl Space {
expires_at: String,
}
let body = json!({ "space": self.uri, "service": service });
let auth = self.for_repo(authority);
let auth = self.for_repo(authority).await?;
let registered: Registered = host
.procedure("com.atproto.space.registerNotify", Some(&body), Some(&auth))
.await?;
Ok(registered.expires_at)
}
/// Joins `uri` as `me`, a member reading `authority`'s repo there:
/// a cached credential while it lasts, else a fresh one, renewed as it
/// runs out. `host` is the authority's PDS.
pub async fn join(
ctx: &Ctx,
host: &Xrpc,
@@ -233,39 +315,36 @@ impl Space {
authority: &str,
me: &Session,
) -> Result<Space> {
if let Some(kept) = Space::kept(ctx, uri, authority, &me.did) {
if let Some(kept) = Space::kept(ctx, host, uri, authority, &me.did) {
return Ok(kept);
}
let key = P256Key::generate();
let credential = credential(ctx, host, uri, me, &key).await?;
Ok(Space::keep(ctx, uri, authority, &me.did, credential, key))
Ok(Space::keep(
ctx, host, uri, authority, &me.did, credential, key,
))
}
/// The credential kept under `ctx` for `did` to read the space `uri`,
/// while it has a minute left in it.
pub fn kept(ctx: &Ctx, uri: &str, authority: &str, did: &str) -> Option<Space> {
let cached = store::read::<Cached>(&kept_at(ctx, uri, did))?;
let key = P256Key::from_bytes(&STANDARD.decode(&cached.key).ok()?).ok()?;
lasts(&cached.credential).then(|| Space::member(uri, authority, &cached.credential, key))
pub fn kept(ctx: &Ctx, host: &Xrpc, uri: &str, authority: &str, did: &str) -> Option<Space> {
let held = kept_credential(ctx, uri, did)?;
Some(Space::with(uri, authority, held, Renew::of(ctx, host, did)))
}
/// Keeps `credential` and its key under `ctx` for `did` to read the
/// space `uri` with until it ends, and reads it with them.
fn keep(
ctx: &Ctx,
host: &Xrpc,
uri: &str,
authority: &str,
did: &str,
credential: String,
key: P256Key,
) -> Space {
let cached = Cached {
credential: credential.clone(),
key: STANDARD.encode(key.to_bytes()),
};
// A credential that cannot be cached only costs the next exchange.
let _ = store::write(&kept_at(ctx, uri, did), &cached, true);
Space::member(uri, authority, &credential, key)
let held = store_credential(ctx, uri, did, credential, key);
Space::with(uri, authority, held, Renew::of(ctx, host, did))
}
/// Joins the space `uri` as `did`, with `token`, a delegation token
@@ -283,10 +362,30 @@ impl Space {
) -> Result<Space> {
let key = P256Key::generate();
let credential = exchange(host, uri, token, &key).await?;
Ok(Space::keep(ctx, uri, authority, did, credential, key))
Ok(Space::keep(ctx, host, uri, authority, did, credential, key))
}
}
/// The credential kept under `ctx` for `did` to read the space `uri`, while
/// it has a minute left in it.
fn kept_credential(ctx: &Ctx, uri: &str, did: &str) -> Option<Held> {
let cached = store::read::<Cached>(&kept_at(ctx, uri, did))?;
let key = P256Key::from_bytes(&STANDARD.decode(&cached.key).ok()?).ok()?;
lasts(&cached.credential).then(|| Held::Credential(cached.credential, Arc::new(key)))
}
/// Keeps `credential` and its key under `ctx` for `did` to read the space
/// `uri` with until it ends.
fn store_credential(ctx: &Ctx, uri: &str, did: &str, credential: String, key: P256Key) -> Held {
let cached = Cached {
credential: credential.clone(),
key: STANDARD.encode(key.to_bytes()),
};
// A credential that cannot be cached only costs the next exchange.
let _ = store::write(&kept_at(ctx, uri, did), &cached, true);
Held::Credential(credential, Arc::new(key))
}
/// Where the credential for `did` to read the space `uri` is kept.
fn kept_at(ctx: &Ctx, uri: &str, did: &str) -> std::path::PathBuf {
let name = format!("{}.{}.json", did.replace(':', "_"), keys::ref_hash(uri));
@@ -596,28 +695,71 @@ impl Admin<'_> {
mod tests {
use super::*;
#[test]
fn signature_bases() {
/// A context with nothing kept, whose accounts are all on `pds`.
fn ctx_on(dir: &std::path::Path, pds: &str) -> Ctx {
let at = |sub: &str| Some(dir.join(sub).to_string_lossy().into_owned());
Ctx::new(crate::env::Env::from_vars(|k| match k {
"KNOTLESS_CONFIG_DIR" => at("config"),
"KNOTLESS_CACHE_DIR" => at("cache"),
"KNOTLESS_SERVICE" => Some(pds.to_string()),
_ => None,
}))
}
/// A JWT that ends `secs` from now.
fn ends_in(secs: i64) -> String {
let now = (keys::now_micros() / 1_000_000) as i64;
P256Key::generate().sign_jwt(&json!({}), &json!({"exp": now + secs}))
}
fn session(did: &str, service: &str, access_jwt: String) -> Session {
Session {
did: did.into(),
handle: "me.test".into(),
service: service.into(),
access_jwt,
refresh_jwt: "r".into(),
oauth: None,
}
}
#[tokio::test]
async fn signature_bases() {
let key = P256Key::generate();
let headers = |auth: &Auth| auth.headers("GET", "http://pds.test/xrpc/x");
let own = Space {
uri: "at://x/space/t/k".into(),
auth: Auth::bearer("t"),
credential: None,
};
assert_eq!(headers(&own.for_repo("did:plc:m")), headers(&own.auth));
let space = Space::member("at://x/space/t/k", "did:plc:o", "cred", key);
let space_headers = headers(&space.auth);
let dir = tempfile::tempdir().unwrap();
let ctx = ctx_on(dir.path(), "http://pds.test");
let me = session("did:plc:o", "http://pds.test", ends_in(600));
let own = Space::owner(&ctx, "at://x/space/t/k", &me);
let own_auth = own.auth().await.unwrap();
assert_eq!(
headers(&own.for_repo("did:plc:m").await.unwrap()),
headers(&own_auth)
);
assert_eq!(headers(&own_auth), headers(&me.auth()));
let (cred, key) = (ends_in(600), Arc::new(key));
let held = Held::Credential(cred.clone(), key.clone());
let host = Xrpc::new(ctx.http.clone(), "http://pds.test");
let space = Space::with(
"at://x/space/t/k",
"did:plc:o",
held,
Renew::of(&ctx, &host, "m"),
);
let space_headers = headers(&space.auth().await.unwrap());
assert_eq!(space_headers.len(), 4);
assert_eq!(headers(&space.for_repo("did:plc:o"))[1], space_headers[1]);
assert_eq!(headers(&space.for_repo("did:plc:m"))[1].1, "did:plc:m");
let key = P256Key::from_bytes(&space.credential.as_ref().unwrap().1.to_bytes()).unwrap();
let to_o = space.for_repo("did:plc:o").await.unwrap();
assert_eq!(headers(&to_o)[1], space_headers[1]);
let to_m = space.for_repo("did:plc:m").await.unwrap();
assert_eq!(headers(&to_m)[1].1, "did:plc:m");
let at = space.at("did:plc:m").auth().await.unwrap();
assert_eq!(headers(&at)[1], headers(&to_m)[1]);
assert_eq!(
space_headers[0],
(
"authorization".into(),
"Atproto-Space-Credential cred".into()
format!("Atproto-Space-Credential {cred}")
)
);
assert_eq!(
@@ -636,7 +778,7 @@ mod tests {
.strip_suffix(':')
.unwrap();
let base = format!(
"\"authorization\": Atproto-Space-Credential cred\n\"atproto-space-audience\": did:plc:o\n\"@signature-params\": {params}"
"\"authorization\": Atproto-Space-Credential {cred}\n\"atproto-space-audience\": did:plc:o\n\"@signature-params\": {params}"
);
key.public()
.verify(base.as_bytes(), &STANDARD.decode(sig).unwrap())
@@ -665,6 +807,110 @@ mod tests {
assert!(!lasts("junk"));
}
/// A PDS that is both the reader's and the space's authority: it gives
/// a credential for each exchange, counting them, and lists the space's
/// repos to a credential or session that has not ended, as the alpha
/// and pds.js refuse one that has, `JwtExpired`.
async fn authority() -> (String, Arc<std::sync::atomic::AtomicUsize>) {
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use std::sync::atomic::{AtomicUsize, Ordering};
let exchanged = Arc::new(AtomicUsize::new(0));
let counted = exchanged.clone();
let list = |headers: HeaderMap| async move {
let authorization = headers["authorization"].to_str().unwrap_or_default();
let jwt = authorization.split(' ').nth(1).unwrap_or_default();
if lasts(jwt) {
return (StatusCode::OK, r#"{"repos":[]}"#);
}
let expired = r#"{"error":"JwtExpired","message":"token expired"}"#;
(StatusCode::UNAUTHORIZED, expired)
};
let router = axum::Router::new()
.route(
"/xrpc/com.atproto.space.getDelegationToken",
get(|| async { axum::Json(json!({"token": "t"})) }),
)
.route(
"/xrpc/com.atproto.space.getSpaceCredential",
post(move || async move {
counted.fetch_add(1, Ordering::SeqCst);
axum::Json(json!({"credential": ends_in(600)}))
}),
)
.route("/xrpc/com.atproto.space.listRepos", get(list));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
(format!("http://{addr}"), exchanged)
}
#[tokio::test]
async fn a_credential_held_past_its_end_is_renewed() {
use std::sync::atomic::Ordering;
let (pds, exchanged) = authority().await;
let dir = tempfile::tempdir().unwrap();
let ctx = ctx_on(dir.path(), &pds);
let host = Xrpc::new(ctx.http.clone(), &pds);
let me = session("did:plc:me", &pds, ends_in(3600));
session::save(&ctx, &me).unwrap();
let uri = "at://did:plc:o/space/t/k";
// Joined a while ago: what it was let in with has ended since, and
// the authority refuses it.
let (credential, key) = (ends_in(-1), P256Key::generate());
let auth = Auth::Headers(signed(
&key,
&format!("Atproto-Space-Credential {credential}"),
"did:plc:o",
));
let params = [("space", uri)];
let refused = host.query::<Value>("com.atproto.space.listRepos", &params, Some(&auth));
let refused = refused.await.unwrap_err();
assert_eq!(XrpcError::of(&refused).unwrap().error, "JwtExpired");
let ended = Held::Credential(credential, Arc::new(key));
let space = Space::with(uri, "did:plc:o", ended, Renew::of(&ctx, &host, &me.did));
let first = space.writers_since(&host, "did:plc:o", None).await.unwrap();
assert_eq!(first, (Vec::new(), None));
assert_eq!(exchanged.load(Ordering::SeqCst), 1);
// Its clones, and the space read from a member's repo, read with
// the one renewal, and so does another reader as the same account,
// from the credential the renewal kept.
space.at("did:plc:m").auth().await.unwrap();
space
.clone()
.moved_since(&host, "did:plc:o", None)
.await
.unwrap();
let ended = Held::Credential(ends_in(-1), Arc::new(P256Key::generate()));
let other = Space::with(uri, "did:plc:o", ended, Renew::of(&ctx, &host, &me.did));
other.moved_since(&host, "did:plc:o", None).await.unwrap();
assert_eq!(exchanged.load(Ordering::SeqCst), 1);
assert!(Space::kept(&ctx, &host, uri, "did:plc:o", &me.did).is_some());
// With no session to ask for another, the reader is told so.
session::remove(&ctx, &me.did);
forget(&ctx);
let ended = Held::Credential(ends_in(-1), Arc::new(P256Key::generate()));
let lost = Space::with(uri, "did:plc:o", ended, Renew::of(&ctx, &host, &me.did));
let err = lost.auth().await.err().unwrap();
assert!(err.to_string().contains("not logged in"), "{err}");
}
#[tokio::test]
async fn an_owner_session_held_past_its_end_is_renewed() {
let (pds, exchanged) = authority().await;
let dir = tempfile::tempdir().unwrap();
let ctx = ctx_on(dir.path(), &pds);
let host = Xrpc::new(ctx.http.clone(), &pds);
let ended = session("did:plc:o", &pds, ends_in(-1));
let space = Space::owner(&ctx, "at://did:plc:o/space/t/k", &ended);
// Its session as it is now, as another read refreshed it.
session::save(&ctx, &session("did:plc:o", &pds, ends_in(3600))).unwrap();
space.moved_since(&host, "did:plc:o", None).await.unwrap();
assert_eq!(exchanged.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn denied_is_an_authority_saying_no() {
let said = |error: &str| {
crates/knotless/src/space_sync.rs+1−1
@@ -323,7 +323,7 @@ async fn vouched(
) -> Result<(Agreed, Vec<u8>, Bytes)> {
let space = theirs.space.as_ref().context("a repo in no space")?;
let agreed = agree(kem)?;
let auth = space::want_mac_from(&space.auth, COMMIT_MAC, &agreed.share);
let auth = space::want_mac_from(&space.auth().await?, COMMIT_MAC, &agreed.share);
let which = [("space", space.uri.as_str()), ("repo", theirs.did())];
let params: Vec<_> = which.into_iter().chain(params.iter().copied()).collect();
let nsid = format!("com.atproto.space.{method}");
docs/design/access.md+5−1
@@ -128,7 +128,11 @@ App passwords cannot mint delegation tokens, so reading someone else's
private repository takes an OAuth login. Bots on a server, such as a
runner, log in through its gateway ([Bots](gateway.md#bots)). The credential and its key are
cached, readable only by the user, and reused until a minute before they
expire.
expire. What holds a space open for longer than a credential lasts, as a
runner watching a private repository does, renews it then, as it renews
a session: from the cache, if another read renewed it already, else with
a fresh exchange. An owner reading with their session renews the session
the same way.
knotless looks for a repository publicly first, then in a space as whoever
is logged in. It caches which of the two it found, along with the key.