authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 00:19:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
log92445a1ff69766728a676fabd182092161c589bb
tree20778cac24765964798079fa9257a7b25852ca9a
parent388ee81a41d3c21cf970f2be561e0d25df99c73a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Use Astheno public profile claims instead of pairwise subjects

Make Astheno provider strokes inherit the username color. Assisted-by: gpt-6

3 files changed, 92 insertions(+), 34 deletions(-)

dashboard/src/guest.rs+71-14
......@@ -291,6 +291,20 @@ fn signed_claims(
291291 Ok(claims)
292292}
293293
294pub(crate) fn astheno_profile(value: &str) -> Option<String> {
295 let url = url::Url::parse(value).ok()?;
296 (url.origin().ascii_serialization() == ASTHENO
297 && url.username().is_empty()
298 && url.password().is_none()
299 && url.query().is_none()
300 && url.fragment().is_none()
301 && url
302 .path()
303 .strip_prefix("/user/")
304 .is_some_and(|id| !id.is_empty() && !id.contains('/')))
305 .then(|| url.into())
306}
307
294308async fn exchange(
295309 http: &reqwest::Client,
296310 provider: &str,
......@@ -299,7 +313,7 @@ async fn exchange(
299313 code: &str,
300314 callback: &str,
301315 flow: &Value,
302) -> Result<(String, String, Option<String>)> {
316) -> Result<(String, String, Option<String>, Option<String>)> {
303317 let form = [
304318 ("client_id", client),
305319 ("client_secret", secret),
......@@ -356,7 +370,7 @@ async fn exchange(
356370 "GitHub couldn't verify your account. Return to Shale and try again.",
357371 )
358372 })?;
359 return Ok((id.to_string(), login.to_owned(), None));
373 return Ok((id.to_string(), login.to_owned(), None, None));
360374 }
361375 let mut form = form.to_vec();
362376 form.push(("state", "none"));
......@@ -441,7 +455,13 @@ async fn exchange(
441455 && url.password().is_none()
442456 })
443457 .map(String::from);
444 Ok((string(&profile["sub"]).to_owned(), name, picture))
458 let public_profile = profile["profile"].as_str().and_then(astheno_profile);
459 Ok((
460 string(&profile["sub"]).to_owned(),
461 name,
462 picture,
463 public_profile,
464 ))
445465}
446466
447467fn account(
......@@ -450,7 +470,12 @@ fn account(
450470 subject: &str,
451471 name: &str,
452472 picture: Option<&str>,
473 public_profile: Option<&str>,
453474) -> Result<String> {
475 let mut attributes = json!({"picture":picture.map(|picture| vec![picture])});
476 if let Some(profile) = public_profile {
477 attributes["profile"] = json!([profile]);
478 }
454479 let mut db = auth.db.lock().unwrap();
455480 let tx = db.transaction()?;
456481 let existing: Option<String> = tx
......@@ -470,7 +495,10 @@ fn account(
470495 }
471496 tx.execute(
472497 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",
473 sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id],
498 sql![
499 json!({"firstName":name,"attributes":attributes}).to_string(),
500 id
501 ],
474502 )?;
475503 tx.commit()?;
476504 return Ok(id);
......@@ -481,10 +509,11 @@ fn account(
481509 } else {
482510 mcp::hash(subject)[..24].to_owned()
483511 };
484 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});
485 if let Some(picture) = picture {
486 profile["attributes"]["picture"] = json!([picture]);
512 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});
513 if picture.is_none() {
514 attributes.as_object_mut().unwrap().remove("picture");
487515 }
516 profile["attributes"] = attributes;
488517 tx.execute(
489518 "INSERT INTO users(id,profile) VALUES (?,?)",
490519 sql![id, profile.to_string()],
......@@ -623,9 +652,16 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
623652 headers
624653 })
625654 .build()?;
626 let (subject, name, picture) =
655 let (subject, name, picture, public_profile) =
627656 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;
628 let id = account(auth, provider, &subject, &name, picture.as_deref())?;
657 let id = account(
658 auth,
659 provider,
660 &subject,
661 &name,
662 picture.as_deref(),
663 public_profile.as_deref(),
664 )?;
629665 auth.create_session(&id, "dashboard", headers, None)
630666 }
631667 .await;
......@@ -790,6 +826,22 @@ mod tests {
790826 }
791827 }
792828
829 #[test]
830 fn public_astheno_profiles_stay_on_the_identity_origin() {
831 let profile = "https://identity.astheno.software/user/00653PKPFWTHWVX7K6NZ06ZW79";
832 assert_eq!(astheno_profile(profile).as_deref(), Some(profile));
833 for value in [
834 "http://identity.astheno.software/user/id",
835 "https://other.test/user/id",
836 "https://identity.astheno.software/user/",
837 "https://identity.astheno.software/user/id/extra",
838 "https://identity.astheno.software/user/id?query=yes",
839 "https://user@identity.astheno.software/user/id",
840 ] {
841 assert!(astheno_profile(value).is_none());
842 }
843 }
844
793845 #[test]
794846 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {
795847 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));
......@@ -803,17 +855,18 @@ mod tests {
803855 {
804856 let db = auth.db.lock().unwrap();
805857 db.execute("INSERT INTO users(id,profile) VALUES ('owner',?)", [json!({"username":"clover","enabled":true,"email":"same@example.invalid","emailVerified":true}).to_string()]).unwrap();
806 db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", [])
858 db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", [])
807859 .unwrap();
808860 }
809 let first = account(&auth, "github", "123", "clover", None).unwrap();
810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
861 let first = account(&auth, "github", "123", "clover", None, None).unwrap();
862 let repeat = account(&auth, "github", "123", "renamed", None, None).unwrap();
811863 let other = account(
812864 &auth,
813865 "astheno",
814866 "123",
815867 "clover",
816868 Some("https://identity.astheno.software/avatar/123"),
869 Some("https://identity.astheno.software/user/public-id"),
817870 )
818871 .unwrap();
819872 assert_eq!(first, repeat);
......@@ -823,7 +876,11 @@ mod tests {
823876 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
824877 "https://identity.astheno.software/avatar/123"
825878 );
826 account(&auth, "astheno", "123", "clover", None).unwrap();
879 account(&auth, "astheno", "123", "clover", None, None).unwrap();
880 assert_eq!(
881 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["profile"][0],
882 "https://identity.astheno.software/user/public-id"
883 );
827884 assert!(
828885 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
829886 .get("picture")
......@@ -854,7 +911,7 @@ mod tests {
854911 )
855912 .unwrap();
856913 }
857 assert!(account(&auth, "github", "123", "clover", None).is_err());
914 assert!(account(&auth, "github", "123", "clover", None, None).is_err());
858915 assert!(
859916 auth.create_session(&other, "file", &HeaderMap::new(), None)
860917 .is_err()
dashboard/src/shale_page.rs+17-19
......@@ -11,7 +11,7 @@ struct Profile {
1111
1212fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
1313 let db = auth.db.lock().unwrap();
14 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'")?;
14 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'")?;
1515 let rows = query.query_map([], |row| {
1616 Ok((
1717 row.get::<_, String>(0)?,
......@@ -19,33 +19,31 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
1919 row.get::<_, String>(2)?,
2020 row.get::<_, String>(3)?,
2121 row.get::<_, Option<String>>(4)?,
22 row.get::<_, Option<String>>(5)?,
2223 ))
2324 })?;
2425 let mut profiles = HashMap::new();
2526 for row in rows {
26 let (username, name, provider, subject, picture) = row?;
27 let (mut url, id, icon) = match provider.as_str() {
28 "github" => (
29 url::Url::parse("https://github.com/")?,
30 name.as_str(),
31 include_str!("../web/sso/github.svg"),
32 ),
33 "astheno" => (
34 url::Url::parse("https://identity.astheno.software/user/")?,
35 subject.as_str(),
36 include_str!("astheno.svg"),
37 ),
27 let (username, name, provider, subject, picture, public_profile) = row?;
28 if name.is_empty() { continue; }
29 let (url, icon) = match provider.as_str() {
30 "github" => {
31 if matches!(name.as_str(), "." | "..") { continue; }
32 let mut url = url::Url::parse("https://github.com/")?;
33 url.path_segments_mut().unwrap().pop_if_empty().push(&name);
34 (String::from(url), include_str!("../web/sso/github.svg"))
35 }
36 "astheno" => {
37 let Some(url) = public_profile.as_deref().and_then(guest::astheno_profile) else { continue; };
38 (url, include_str!("astheno.svg"))
39 }
3840 _ => continue,
3941 };
40 if name.is_empty() || id.is_empty() || matches!(id, "." | "..") {
41 continue;
42 }
43 url.path_segments_mut().unwrap().pop_if_empty().push(id);
4442 profiles.insert(
4543 username,
4644 Profile {
4745 name,
48 url: url.into(),
46 url,
4947 icon,
5048 picture: if provider == "github" {
5149 Some(format!(
......@@ -90,7 +88,7 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -
9088 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
9189 element.prepend(&format!("<img src=\"{picture}\" alt=\"\" width=\"16\" height=\"16\" referrerpolicy=\"no-referrer\" style=\"width:1em;height:1em;object-fit:cover;vertical-align:-.125em;margin-right:.3em\">"), ContentType::Html);
9290 }
93 let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" ");
91 let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" ");
9492 let icon = if profile.url.starts_with("https://github.com/") { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };
9593 element.append(&icon, ContentType::Html);
9694 element.append(&profile.name, ContentType::Text);
tools/dashboard-shale-page-test.py+4-1
......@@ -119,9 +119,11 @@ def main():
119119 assert time.monotonic() < deadline
120120 time.sleep(.05)
121121 with sqlite3.connect(data / 'accounts.sqlite') as db:
122 for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', '00653DG7HZ7MCGTW2BPG7RMGQB', 'abc', 'Astheno user'), ('github', '777', '777', None)]:
122 for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', 'pairwise-astheno-subject', 'abc', 'Astheno user'), ('github', '777', '777', None), ('astheno', 'unmapped-pairwise-subject', 'unknown', 'Unmapped guest')]:
123123 username = f'guest-{provider}-{suffix}'
124124 profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name}
125 if provider == 'astheno' and suffix == 'abc':
126 profile['attributes'] = {'profile': ['https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']}
125127 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])
126128 db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username])
127129 os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token))
......@@ -198,6 +200,7 @@ def main():
198200 for path, target in [('/~guest-github-24465214', 'https://github.com/paperclover'), ('/~guest-astheno-abc/', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB')]:
199201 status, headers, body = request(path)
200202 assert status == 302 and headers['Location'] == target and headers['Referrer-Policy'] == 'no-referrer', (path, status, dict(headers), observations[-1][:2])
203 assert request('/~guest-astheno-unknown')[0] == 200
201204 status, headers, body = request('/redirect')
202205 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2
203206 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response: