~ 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

knotless login's page fits in the browser's Referer again

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:cli/short-login-url
.changeset/login-url-fits-browser.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: patch
---
# `knotless login` works in Chrome and Firefox again
The login page's address had grown past what Chrome and Firefox send back to it, so approving a login on bsky.social and other PDSes failed with "Invalid referer". `knotless login` now asks for the same access in a shorter form. A login made before stays current.
crates/knotless/src/oauth.rs+75−14
@@ -33,11 +33,21 @@ const CALLBACK: &str = "/callback";
/// in an account, to upload the blobs that hold its packs and pictures, to call gateways
/// and moderation services as the account (its operator's methods too), and the spaces private
/// repositories and organizations live in. A PDS without spaces drops the
/// scope it does not know. Each space scope names its type, with the
/// collections that type's lexicon lists ([`space_scope`]): an
/// scope it does not know. Each space scope names its type alone: an
/// authorization server resolves the lexicon of each type a scope names,
/// and refuses the login if it cannot.
pub static SCOPE: LazyLock<String> = LazyLock::new(|| {
/// refuses the login if it cannot, and grants the collections it lists.
/// The methods called are one scope, each an `lxm` of it. Both keep the
/// loopback client's ID, which carries this scope, short enough for the
/// browser ([`client_id`]).
pub static SCOPE: LazyLock<String> = LazyLock::new(|| scope(false));
/// [`SCOPE`] as logins asked for it before, a scope for each method and
/// each space type's collections named: the same grant, so such a login is
/// not behind.
static SPELLED_OUT: LazyLock<String> = LazyLock::new(|| scope(true));
/// What knotless asks for, spelled out as before when `spelled`.
fn scope(spelled: bool) -> String {
let mut scope = vec!["atproto".to_string()];
let written = nsid::ALL.iter().chain(&nsid::FORGE).chain([
&nsid::EMAILS,
@@ -118,13 +128,22 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| {
crate::notifications::LIST_SUBSCRIPTIONS,
crate::account::FORGET,
];
scope.extend(called.map(|lxm| format!("rpc:{lxm}?aud=*")));
match spelled {
true => scope.extend(called.map(|lxm| format!("rpc:{lxm}?aud=*"))),
false => {
let lxms: String = called.map(|lxm| format!("lxm={lxm}&")).concat();
scope.push(format!("rpc?{lxms}aud=*"));
}
}
let manage = "&manage=create&manage=update&manage=delete";
for lexicon in [SPACE_LEXICON, ORG_SPACE_LEXICON] {
scope.push(space_scope(lexicon, "*", manage));
scope.push(match spelled {
true => space_scope(lexicon, "*", manage),
false => format!("space:{}?authority=*{manage}", space_type(lexicon)),
});
}
scope.join(" ")
});
}
/// The lexicon of [`crate::space::SPACE_TYPE`], a private repository's.
pub const SPACE_LEXICON: &str = include_str!("../../../lexicons/dev/knotless/space.json");
@@ -142,10 +161,16 @@ pub fn space_scope(lexicon: &str, authority: &str, manage: &str) -> String {
.filter_map(Value::as_str)
.map(|c| format!("&collection={c}"))
.collect();
let id = doc["id"].as_str().unwrap_or_default();
let id = space_type(lexicon);
format!("space:{id}?authority={authority}{collections}{manage}")
}
/// The space type `lexicon` defines.
fn space_type(lexicon: &str) -> String {
let doc: Value = serde_json::from_str(lexicon).expect("a space lexicon is JSON");
doc["id"].as_str().unwrap_or_default().to_string()
}
/// What a call refused for want of scope says in place of the PDS's
/// words, when the session refused was the caller's own login.
pub const LOGIN_BEHIND: &str =
@@ -157,10 +182,16 @@ pub const GRANT_BEHIND: &str = "This account's grant to the gateway is older tha
/// Whether `session` was asked for less than `asked`, what its client asks
/// for now: logging in again, or delegating again, brings it up. A session
/// stored before its scope was kept is behind, unless it was the loopback
/// client's, whose ID names its scope; one by app password never is.
/// client's, whose ID names its scope; one by app password never is. One
/// asked for [`SCOPE`] with its space types' collections spelled out holds
/// the same grant.
pub fn behind(session: &Session, asked: &str) -> bool {
let oauth = session.oauth.as_ref();
oauth.is_some_and(|o| o.scope.clone().or_else(|| loopback_scope(o)).as_deref() != Some(asked))
let Some(oauth) = session.oauth.as_ref() else {
return false;
};
let kept = oauth.scope.clone().or_else(|| loopback_scope(oauth));
let same = |s: &str| s == asked || (asked == *SCOPE && s == *SPELLED_OUT);
!kept.as_deref().is_some_and(same)
}
/// The scope the loopback client ID `oauth` was granted to names.
@@ -875,6 +906,10 @@ mod tests {
assert!(behind(&session, &SCOPE));
session.oauth.as_mut().unwrap().scope = Some(SCOPE.clone());
assert!(!behind(&session, &SCOPE));
// Asked with each space type's collections named: the same grant.
session.oauth.as_mut().unwrap().scope = Some(SPELLED_OUT.clone());
assert!(!behind(&session, &SCOPE));
assert!(behind(&session, gateway::SCOPE));
// A login that named every space type is behind one that names
// knotless's alone, and a gateway's grant so too: both ask again.
let broad = SCOPE.split(" space:").next().unwrap().to_string()
@@ -927,6 +962,24 @@ mod tests {
assert_eq!(origin("nowhere"), "");
}
/// Browsers cut a Referer longer than this to its origin, and the
/// reference PDS refuses an approval whose Referer is not the
/// authorization page's URL, client ID and all.
const REFERER_LIMIT: usize = 4096;
#[test]
fn login_page_url_fits_a_referer() {
let mut url = Url::parse("https://bsky.social/oauth/authorize").unwrap();
url.query_pairs_mut()
.append_pair("client_id", &client_id())
.append_pair(
"request_uri",
&format!("urn:ietf:params:oauth:request_uri:req-{}", "0".repeat(32)),
);
let len = url.as_str().len();
assert!(len < REFERER_LIMIT, "the login page's URL is {len} bytes");
}
#[test]
fn client() {
let id = client_id();
@@ -942,14 +995,22 @@ mod tests {
};
assert_eq!(named("dev.knotless.space"), 1);
assert_eq!(named("dev.knotless.orgSpace"), 1);
assert!(SCOPE.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&manage=create&manage=update&manage=delete"));
assert!(SCOPE.ends_with(" space:dev.knotless.orgSpace?authority=*&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update&manage=delete"));
// The PDS grants each type's collections, so they go unnamed.
assert!(!SCOPE.contains("collection="));
assert!(SCOPE.ends_with(" space:dev.knotless.space?authority=*&manage=create&manage=update&manage=delete space:dev.knotless.orgSpace?authority=*&manage=create&manage=update&manage=delete"));
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&manage=create&manage=update&manage=delete"));
assert!(SPELLED_OUT.ends_with(" space:dev.knotless.orgSpace?authority=*&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update&manage=delete"));
assert!(SCOPE.contains(" repo:dev.knotless.pull "));
assert!(SCOPE.contains(" repo:dev.knotless.check "));
assert!(SCOPE.contains(" repo:dev.knotless.minutes "));
assert!(SCOPE.contains(" repo:dev.knotless.theme "));
assert!(SCOPE.contains(" blob:text/plain "));
assert!(SCOPE.contains(" rpc:dev.knotless.putRunOutput?aud=* "));
// Every method called, as one scope.
assert_eq!(SCOPE.matches(" rpc").count(), 1);
assert!(SCOPE.contains(" rpc?lxm=dev.knotless.pushRefs&"));
assert!(SCOPE.contains("&lxm=dev.knotless.putRunOutput&"));
assert!(SCOPE.contains("&lxm=dev.knotless.deleteAccountData&aud=* "));
assert!(SPELLED_OUT.contains(" rpc:dev.knotless.putRunOutput?aud=* "));
assert_eq!(SCOPE.matches(" blob:image/png ").count(), 1);
assert!(!SCOPE.contains("transition:"));
}