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(...@@ -291,6 +291,20 @@ fn signed_claims(
291 Ok(claims)291 Ok(claims)
292}292}
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
294async fn exchange(308async fn exchange(
295 http: &reqwest::Client,309 http: &reqwest::Client,
296 provider: &str,310 provider: &str,
...@@ -299,7 +313,7 @@ async fn exchange(...@@ -299,7 +313,7 @@ async fn exchange(
299 code: &str,313 code: &str,
300 callback: &str,314 callback: &str,
301 flow: &Value,315 flow: &Value,
302) -> Result<(String, String, Option<String>)> {316) -> Result<(String, String, Option<String>, Option<String>)> {
303 let form = [317 let form = [
304 ("client_id", client),318 ("client_id", client),
305 ("client_secret", secret),319 ("client_secret", secret),
...@@ -356,7 +370,7 @@ async fn exchange(...@@ -356,7 +370,7 @@ async fn exchange(
356 "GitHub couldn't verify your account. Return to Shale and try again.",370 "GitHub couldn't verify your account. Return to Shale and try again.",
357 )371 )
358 })?;372 })?;
359 return Ok((id.to_string(), login.to_owned(), None));373 return Ok((id.to_string(), login.to_owned(), None, None));
360 }374 }
361 let mut form = form.to_vec();375 let mut form = form.to_vec();
362 form.push(("state", "none"));376 form.push(("state", "none"));
...@@ -441,7 +455,13 @@ async fn exchange(...@@ -441,7 +455,13 @@ async fn exchange(
441 && url.password().is_none()455 && url.password().is_none()
442 })456 })
443 .map(String::from);457 .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 ))
445}465}
446466
447fn account(467fn account(
...@@ -450,7 +470,12 @@ fn account(...@@ -450,7 +470,12 @@ fn account(
450 subject: &str,470 subject: &str,
451 name: &str,471 name: &str,
452 picture: Option<&str>,472 picture: Option<&str>,
473 public_profile: Option<&str>,
453) -> Result<String> {474) -> 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 }
454 let mut db = auth.db.lock().unwrap();479 let mut db = auth.db.lock().unwrap();
455 let tx = db.transaction()?;480 let tx = db.transaction()?;
456 let existing: Option<String> = tx481 let existing: Option<String> = tx
...@@ -470,7 +495,10 @@ fn account(...@@ -470,7 +495,10 @@ fn account(
470 }495 }
471 tx.execute(496 tx.execute(
472 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",497 "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 ],
474 )?;502 )?;
475 tx.commit()?;503 tx.commit()?;
476 return Ok(id);504 return Ok(id);
...@@ -481,10 +509,11 @@ fn account(...@@ -481,10 +509,11 @@ fn account(
481 } else {509 } else {
482 mcp::hash(subject)[..24].to_owned()510 mcp::hash(subject)[..24].to_owned()
483 };511 };
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});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});
485 if let Some(picture) = picture {513 if picture.is_none() {
486 profile["attributes"]["picture"] = json!([picture]);514 attributes.as_object_mut().unwrap().remove("picture");
487 }515 }
516 profile["attributes"] = attributes;
488 tx.execute(517 tx.execute(
489 "INSERT INTO users(id,profile) VALUES (?,?)",518 "INSERT INTO users(id,profile) VALUES (?,?)",
490 sql![id, profile.to_string()],519 sql![id, profile.to_string()],
...@@ -623,9 +652,16 @@ async fn handle(app: &App, request: Request) -> Result<Response> {...@@ -623,9 +652,16 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
623 headers652 headers
624 })653 })
625 .build()?;654 .build()?;
626 let (subject, name, picture) =655 let (subject, name, picture, public_profile) =
627 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;656 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 )?;
629 auth.create_session(&id, "dashboard", headers, None)665 auth.create_session(&id, "dashboard", headers, None)
630 }666 }
631 .await;667 .await;
...@@ -790,6 +826,22 @@ mod tests {...@@ -790,6 +826,22 @@ mod tests {
790 }826 }
791 }827 }
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
793 #[test]845 #[test]
794 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {846 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {
795 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));847 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));
...@@ -803,17 +855,18 @@ mod tests {...@@ -803,17 +855,18 @@ mod tests {
803 {855 {
804 let db = auth.db.lock().unwrap();856 let db = auth.db.lock().unwrap();
805 db.execute("INSERT INTO users(id,profile) VALUES ('owner',?)", [json!({"username":"clover","enabled":true,"email":"same@example.invalid","emailVerified":true}).to_string()]).unwrap();857 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", [])
807 .unwrap();859 .unwrap();
808 }860 }
809 let first = account(&auth, "github", "123", "clover", None).unwrap();861 let first = account(&auth, "github", "123", "clover", None, None).unwrap();
810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();862 let repeat = account(&auth, "github", "123", "renamed", None, None).unwrap();
811 let other = account(863 let other = account(
812 &auth,864 &auth,
813 "astheno",865 "astheno",
814 "123",866 "123",
815 "clover",867 "clover",
816 Some("https://identity.astheno.software/avatar/123"),868 Some("https://identity.astheno.software/avatar/123"),
869 Some("https://identity.astheno.software/user/public-id"),
817 )870 )
818 .unwrap();871 .unwrap();
819 assert_eq!(first, repeat);872 assert_eq!(first, repeat);
...@@ -823,7 +876,11 @@ mod tests {...@@ -823,7 +876,11 @@ mod tests {
823 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],876 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
824 "https://identity.astheno.software/avatar/123"877 "https://identity.astheno.software/avatar/123"
825 );878 );
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 );
827 assert!(884 assert!(
828 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]885 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
829 .get("picture")886 .get("picture")
...@@ -854,7 +911,7 @@ mod tests {...@@ -854,7 +911,7 @@ mod tests {
854 )911 )
855 .unwrap();912 .unwrap();
856 }913 }
857 assert!(account(&auth, "github", "123", "clover", None).is_err());914 assert!(account(&auth, "github", "123", "clover", None, None).is_err());
858 assert!(915 assert!(
859 auth.create_session(&other, "file", &HeaderMap::new(), None)916 auth.create_session(&other, "file", &HeaderMap::new(), None)
860 .is_err()917 .is_err()
dashboard/src/shale_page.rs+17-19
...@@ -11,7 +11,7 @@ struct Profile {...@@ -11,7 +11,7 @@ struct Profile {
1111
12fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {12fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
13 let db = auth.db.lock().unwrap();13 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'")?;
15 let rows = query.query_map([], |row| {15 let rows = query.query_map([], |row| {
16 Ok((16 Ok((
17 row.get::<_, String>(0)?,17 row.get::<_, String>(0)?,
...@@ -19,33 +19,31 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {...@@ -19,33 +19,31 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
19 row.get::<_, String>(2)?,19 row.get::<_, String>(2)?,
20 row.get::<_, String>(3)?,20 row.get::<_, String>(3)?,
21 row.get::<_, Option<String>>(4)?,21 row.get::<_, Option<String>>(4)?,
22 row.get::<_, Option<String>>(5)?,
22 ))23 ))
23 })?;24 })?;
24 let mut profiles = HashMap::new();25 let mut profiles = HashMap::new();
25 for row in rows {26 for row in rows {
26 let (username, name, provider, subject, picture) = row?;27 let (username, name, provider, subject, picture, public_profile) = row?;
27 let (mut url, id, icon) = match provider.as_str() {28 if name.is_empty() { continue; }
28 "github" => (29 let (url, icon) = match provider.as_str() {
29 url::Url::parse("https://github.com/")?,30 "github" => {
30 name.as_str(),31 if matches!(name.as_str(), "." | "..") { continue; }
31 include_str!("../web/sso/github.svg"),32 let mut url = url::Url::parse("https://github.com/")?;
32 ),33 url.path_segments_mut().unwrap().pop_if_empty().push(&name);
33 "astheno" => (34 (String::from(url), include_str!("../web/sso/github.svg"))
34 url::Url::parse("https://identity.astheno.software/user/")?,35 }
35 subject.as_str(),36 "astheno" => {
36 include_str!("astheno.svg"),37 let Some(url) = public_profile.as_deref().and_then(guest::astheno_profile) else { continue; };
37 ),38 (url, include_str!("astheno.svg"))
39 }
38 _ => continue,40 _ => continue,
39 };41 };
40 if name.is_empty() || id.is_empty() || matches!(id, "." | "..") {
41 continue;
42 }
43 url.path_segments_mut().unwrap().pop_if_empty().push(id);
44 profiles.insert(42 profiles.insert(
45 username,43 username,
46 Profile {44 Profile {
47 name,45 name,
48 url: url.into(),46 url,
49 icon,47 icon,
50 picture: if provider == "github" {48 picture: if provider == "github" {
51 Some(format!(49 Some(format!(
...@@ -90,7 +88,7 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -...@@ -90,7 +88,7 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -
90 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");88 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
91 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);89 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);
92 }90 }
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\" ");
94 let icon = if profile.url.starts_with("https://github.com/") { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };92 let icon = if profile.url.starts_with("https://github.com/") { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };
95 element.append(&icon, ContentType::Html);93 element.append(&icon, ContentType::Html);
96 element.append(&profile.name, ContentType::Text);94 element.append(&profile.name, ContentType::Text);
tools/dashboard-shale-page-test.py+4-1
...@@ -119,9 +119,11 @@ def main():...@@ -119,9 +119,11 @@ def main():
119 assert time.monotonic() < deadline119 assert time.monotonic() < deadline
120 time.sleep(.05)120 time.sleep(.05)
121 with sqlite3.connect(data / 'accounts.sqlite') as db:121 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')]:
123 username = f'guest-{provider}-{suffix}'123 username = f'guest-{provider}-{suffix}'
124 profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name}124 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']}
125 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])127 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])
126 db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username])128 db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username])
127 os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token))129 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():...@@ -198,6 +200,7 @@ def main():
198 for path, target in [('/~guest-github-24465214', 'https://github.com/paperclover'), ('/~guest-astheno-abc/', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB')]:200 for path, target in [('/~guest-github-24465214', 'https://github.com/paperclover'), ('/~guest-astheno-abc/', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB')]:
199 status, headers, body = request(path)201 status, headers, body = request(path)
200 assert status == 302 and headers['Location'] == target and headers['Referrer-Policy'] == 'no-referrer', (path, status, dict(headers), observations[-1][:2])202 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
201 status, headers, body = request('/redirect')204 status, headers, body = request('/redirect')
202 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2205 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2
203 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response:206 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response: