From 92445a1ff69766728a676fabd182092161c589bb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 00:19:46 -0700 Subject: [PATCH] Use Astheno public profile claims instead of pairwise subjects Make Astheno provider strokes inherit the username color. Assisted-by: gpt-6 --- dashboard/src/guest.rs | 85 +++++++++++++++++++++++++----- dashboard/src/shale_page.rs | 36 ++++++------- tools/dashboard-shale-page-test.py | 5 +- 3 files changed, 92 insertions(+), 34 deletions(-) diff --git a/dashboard/src/guest.rs b/dashboard/src/guest.rs index 3e4c592074438ecea29c3a416bf6b0f916ac1325..ceb776e7339f7cee57acdecba787a7720664bfa5 100644 --- a/dashboard/src/guest.rs +++ b/dashboard/src/guest.rs @@ -291,6 +291,20 @@ fn signed_claims( Ok(claims) } +pub(crate) fn astheno_profile(value: &str) -> Option { + let url = url::Url::parse(value).ok()?; + (url.origin().ascii_serialization() == ASTHENO + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && url + .path() + .strip_prefix("/user/") + .is_some_and(|id| !id.is_empty() && !id.contains('/'))) + .then(|| url.into()) +} + async fn exchange( http: &reqwest::Client, provider: &str, @@ -299,7 +313,7 @@ async fn exchange( code: &str, callback: &str, flow: &Value, -) -> Result<(String, String, Option)> { +) -> Result<(String, String, Option, Option)> { let form = [ ("client_id", client), ("client_secret", secret), @@ -356,7 +370,7 @@ async fn exchange( "GitHub couldn't verify your account. Return to Shale and try again.", ) })?; - return Ok((id.to_string(), login.to_owned(), None)); + return Ok((id.to_string(), login.to_owned(), None, None)); } let mut form = form.to_vec(); form.push(("state", "none")); @@ -441,7 +455,13 @@ async fn exchange( && url.password().is_none() }) .map(String::from); - Ok((string(&profile["sub"]).to_owned(), name, picture)) + let public_profile = profile["profile"].as_str().and_then(astheno_profile); + Ok(( + string(&profile["sub"]).to_owned(), + name, + picture, + public_profile, + )) } fn account( @@ -450,7 +470,12 @@ fn account( subject: &str, name: &str, picture: Option<&str>, + public_profile: Option<&str>, ) -> Result { + let mut attributes = json!({"picture":picture.map(|picture| vec![picture])}); + if let Some(profile) = public_profile { + attributes["profile"] = json!([profile]); + } let mut db = auth.db.lock().unwrap(); let tx = db.transaction()?; let existing: Option = tx @@ -470,7 +495,10 @@ fn account( } tx.execute( "UPDATE users SET profile=json_patch(profile,?) WHERE id=?", - sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id], + sql![ + json!({"firstName":name,"attributes":attributes}).to_string(), + id + ], )?; tx.commit()?; return Ok(id); @@ -481,10 +509,11 @@ fn account( } else { mcp::hash(subject)[..24].to_owned() }; - let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"attributes":{},"createdTimestamp":(now()*1000.0) as i64}); - if let Some(picture) = picture { - profile["attributes"]["picture"] = json!([picture]); + let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"createdTimestamp":(now()*1000.0) as i64}); + if picture.is_none() { + attributes.as_object_mut().unwrap().remove("picture"); } + profile["attributes"] = attributes; tx.execute( "INSERT INTO users(id,profile) VALUES (?,?)", sql![id, profile.to_string()], @@ -623,9 +652,16 @@ async fn handle(app: &App, request: Request) -> Result { headers }) .build()?; - let (subject, name, picture) = + let (subject, name, picture, public_profile) = exchange(&http, provider, &client, &secret, code, &callback, &flow).await?; - let id = account(auth, provider, &subject, &name, picture.as_deref())?; + let id = account( + auth, + provider, + &subject, + &name, + picture.as_deref(), + public_profile.as_deref(), + )?; auth.create_session(&id, "dashboard", headers, None) } .await; @@ -790,6 +826,22 @@ mod tests { } } + #[test] + fn public_astheno_profiles_stay_on_the_identity_origin() { + let profile = "https://identity.astheno.software/user/00653PKPFWTHWVX7K6NZ06ZW79"; + assert_eq!(astheno_profile(profile).as_deref(), Some(profile)); + for value in [ + "http://identity.astheno.software/user/id", + "https://other.test/user/id", + "https://identity.astheno.software/user/", + "https://identity.astheno.software/user/id/extra", + "https://identity.astheno.software/user/id?query=yes", + "https://user@identity.astheno.software/user/id", + ] { + assert!(astheno_profile(value).is_none()); + } + } + #[test] fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() { let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4())); @@ -803,17 +855,18 @@ mod tests { { let db = auth.db.lock().unwrap(); db.execute("INSERT INTO users(id,profile) VALUES ('owner',?)", [json!({"username":"clover","enabled":true,"email":"same@example.invalid","emailVerified":true}).to_string()]).unwrap(); - db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", []) + db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", []) .unwrap(); } - let first = account(&auth, "github", "123", "clover", None).unwrap(); - let repeat = account(&auth, "github", "123", "renamed", None).unwrap(); + let first = account(&auth, "github", "123", "clover", None, None).unwrap(); + let repeat = account(&auth, "github", "123", "renamed", None, None).unwrap(); let other = account( &auth, "astheno", "123", "clover", Some("https://identity.astheno.software/avatar/123"), + Some("https://identity.astheno.software/user/public-id"), ) .unwrap(); assert_eq!(first, repeat); @@ -823,7 +876,11 @@ mod tests { auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0], "https://identity.astheno.software/avatar/123" ); - account(&auth, "astheno", "123", "clover", None).unwrap(); + account(&auth, "astheno", "123", "clover", None, None).unwrap(); + assert_eq!( + auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["profile"][0], + "https://identity.astheno.software/user/public-id" + ); assert!( auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"] .get("picture") @@ -854,7 +911,7 @@ mod tests { ) .unwrap(); } - assert!(account(&auth, "github", "123", "clover", None).is_err()); + assert!(account(&auth, "github", "123", "clover", None, None).is_err()); assert!( auth.create_session(&other, "file", &HeaderMap::new(), None) .is_err() diff --git a/dashboard/src/shale_page.rs b/dashboard/src/shale_page.rs index cfc5c63249a120fc9ac6d4c70e8686d7f194c70a..3622018506b6bb6a0d9472978b0b003a8231028b 100644 --- a/dashboard/src/shale_page.rs +++ b/dashboard/src/shale_page.rs @@ -11,7 +11,7 @@ struct Profile { fn profiles(auth: &auth::Store) -> Result> { let db = auth.db.lock().unwrap(); - let mut query = db.prepare("SELECT json_extract(profile,'$.username'),coalesce(json_extract(profile,'$.firstName'),''),provider,subject,json_extract(profile,'$.attributes.picture[0]') FROM users JOIN external_identities ON user_id=users.id WHERE json_extract(profile,'$.kind')='guest'")?; + let mut query = db.prepare("SELECT json_extract(profile,'$.username'),coalesce(json_extract(profile,'$.firstName'),''),provider,subject,json_extract(profile,'$.attributes.picture[0]'),json_extract(profile,'$.attributes.profile[0]') FROM users JOIN external_identities ON user_id=users.id WHERE json_extract(profile,'$.kind')='guest'")?; let rows = query.query_map([], |row| { Ok(( row.get::<_, String>(0)?, @@ -19,33 +19,31 @@ fn profiles(auth: &auth::Store) -> Result> { row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, )) })?; let mut profiles = HashMap::new(); for row in rows { - let (username, name, provider, subject, picture) = row?; - let (mut url, id, icon) = match provider.as_str() { - "github" => ( - url::Url::parse("https://github.com/")?, - name.as_str(), - include_str!("../web/sso/github.svg"), - ), - "astheno" => ( - url::Url::parse("https://identity.astheno.software/user/")?, - subject.as_str(), - include_str!("astheno.svg"), - ), + let (username, name, provider, subject, picture, public_profile) = row?; + if name.is_empty() { continue; } + let (url, icon) = match provider.as_str() { + "github" => { + if matches!(name.as_str(), "." | "..") { continue; } + let mut url = url::Url::parse("https://github.com/")?; + url.path_segments_mut().unwrap().pop_if_empty().push(&name); + (String::from(url), include_str!("../web/sso/github.svg")) + } + "astheno" => { + let Some(url) = public_profile.as_deref().and_then(guest::astheno_profile) else { continue; }; + (url, include_str!("astheno.svg")) + } _ => continue, }; - if name.is_empty() || id.is_empty() || matches!(id, "." | "..") { - continue; - } - url.path_segments_mut().unwrap().pop_if_empty().push(id); profiles.insert( username, Profile { name, - url: url.into(), + url, icon, picture: if provider == "github" { Some(format!( @@ -90,7 +88,7 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap) - let picture = picture.replace('&', "&").replace('"', """).replace('<', "<"); element.prepend(&format!("\"\""), ContentType::Html); } - let icon = profile.icon.trim().replace("