| ... | @@ -291,6 +291,20 @@ fn signed_claims( | ... | @@ -291,6 +291,20 @@ fn signed_claims( |
| 291 | Ok(claims) | 291 | Ok(claims) |
| 292 | } | 292 | } |
| 293 | | 293 | |
| | 294 | pub(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 | |
| 294 | async fn exchange( | 308 | async 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 | } |
| 446 | | 466 | |
| 447 | fn account( | 467 | fn 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> = tx | 481 | 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 | headers | 652 | 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 | } |
| 792 | | 828 | |
| | 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() |