chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless login's page fits in the browser's Referer again
.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:")); | ||
} | ||