Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(auth): no bsky chat access when not specifically privileged to have it

Lewis: May this revision serve well! <lu5a@proton.me>

+203 -10
+13 -4
crates/tranquil-api/src/common.rs
··· 231 231 app_passwords 232 232 .into_iter() 233 233 .find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false)) 234 - .map(|app| CredentialMatch::AppPassword { 235 - name: app.name, 236 - scopes: app.scopes, 237 - controller_did: app.created_by_controller_did, 234 + .map(|app| { 235 + let scopes = app.scopes.unwrap_or_else(|| { 236 + if app.privilege.is_privileged() { 237 + "transition:generic transition:chat.bsky".to_string() 238 + } else { 239 + "transition:generic".to_string() 240 + } 241 + }); 242 + CredentialMatch::AppPassword { 243 + name: app.name, 244 + scopes: Some(scopes), 245 + controller_did: app.created_by_controller_did, 246 + } 238 247 }) 239 248 } 240 249
+8 -1
crates/tranquil-api/src/server/app_password.rs
··· 132 132 }; 133 133 (scope_result, Some(controller.clone())) 134 134 } else { 135 - (input.scopes.clone(), None) 135 + let scopes = match input.scopes { 136 + Some(ref s) => s.clone(), 137 + None => match input.privileged { 138 + Some(false) => "transition:generic".to_string(), 139 + _ => "transition:generic transition:chat.bsky".to_string(), 140 + }, 141 + }; 142 + (Some(scopes), None) 136 143 }; 137 144 138 145 let password = generate_app_password();
+1 -1
crates/tranquil-api/src/server/passkey_account.rs
··· 401 401 refresh_expires_at: refresh_expires, 402 402 login_type: tranquil_db_traits::LoginType::Modern, 403 403 mfa_verified: false, 404 - scope: Some("transition:generic".to_string()), 404 + scope: Some("transition:generic transition:chat.bsky".to_string()), 405 405 controller_did: None, 406 406 app_password_name: None, 407 407 };
+1 -1
crates/tranquil-oauth-server/src/sso_endpoints.rs
··· 1339 1339 refresh_expires_at: refresh_meta.expires_at, 1340 1340 login_type: tranquil_db_traits::LoginType::Modern, 1341 1341 mfa_verified: false, 1342 - scope: Some("transition:generic".to_string()), 1342 + scope: Some("transition:generic transition:chat.bsky".to_string()), 1343 1343 controller_did: None, 1344 1344 app_password_name: None, 1345 1345 };
+152
crates/tranquil-pds/tests/lifecycle_session.rs
··· 597 597 "Token should not be expired" 598 598 ); 599 599 } 600 + 601 + async fn create_app_password_session( 602 + client: &reqwest::Client, 603 + did: &str, 604 + main_jwt: &str, 605 + name: &str, 606 + body: Value, 607 + ) -> (String, Value) { 608 + let base = base_url().await; 609 + let create_res = client 610 + .post(format!( 611 + "{}/xrpc/com.atproto.server.createAppPassword", 612 + base 613 + )) 614 + .bearer_auth(main_jwt) 615 + .json(&body) 616 + .send() 617 + .await 618 + .expect("Failed to create app password"); 619 + assert_eq!(create_res.status(), StatusCode::OK); 620 + let app_pass: Value = create_res.json().await.unwrap(); 621 + let password = app_pass["password"].as_str().unwrap().to_string(); 622 + let scopes_response = app_pass.clone(); 623 + let login_res = client 624 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 625 + .json(&json!({ "identifier": did, "password": password })) 626 + .send() 627 + .await 628 + .expect("Failed to login with app password"); 629 + assert_eq!(login_res.status(), StatusCode::OK, "App password login for '{}' failed", name); 630 + let session: Value = login_res.json().await.unwrap(); 631 + let jwt = session["accessJwt"].as_str().unwrap().to_string(); 632 + (jwt, scopes_response) 633 + } 634 + 635 + async fn try_chat_service_auth(client: &reqwest::Client, jwt: &str) -> StatusCode { 636 + let base = base_url().await; 637 + let res = client 638 + .get(format!( 639 + "{}/xrpc/com.atproto.server.getServiceAuth", 640 + base 641 + )) 642 + .bearer_auth(jwt) 643 + .query(&[ 644 + ("aud", "did:web:api.bsky.app"), 645 + ("lxm", "chat.bsky.convo.listConvos"), 646 + ]) 647 + .send() 648 + .await 649 + .expect("Failed to call getServiceAuth"); 650 + res.status() 651 + } 652 + 653 + #[tokio::test] 654 + async fn test_app_password_non_privileged_blocks_chat() { 655 + let client = client(); 656 + let (did, jwt) = setup_new_user("appscope-nonchat").await; 657 + let (app_jwt, create_body) = create_app_password_session( 658 + &client, 659 + &did, 660 + &jwt, 661 + "non-privileged", 662 + json!({ "name": "NoChatApp", "privileged": false }), 663 + ) 664 + .await; 665 + assert_eq!( 666 + create_body["scopes"].as_str().unwrap(), 667 + "transition:generic", 668 + "Non-privileged app password should not have chat scope" 669 + ); 670 + let status = try_chat_service_auth(&client, &app_jwt).await; 671 + assert_eq!( 672 + status, 673 + StatusCode::FORBIDDEN, 674 + "Non-privileged app password must not access chat methods" 675 + ); 676 + } 677 + 678 + #[tokio::test] 679 + async fn test_app_password_privileged_allows_chat() { 680 + let client = client(); 681 + let (did, jwt) = setup_new_user("appscope-chat").await; 682 + let (app_jwt, create_body) = create_app_password_session( 683 + &client, 684 + &did, 685 + &jwt, 686 + "privileged", 687 + json!({ "name": "ChatApp", "privileged": true }), 688 + ) 689 + .await; 690 + assert_eq!( 691 + create_body["scopes"].as_str().unwrap(), 692 + "transition:generic transition:chat.bsky", 693 + "Privileged app password should have chat scope" 694 + ); 695 + let status = try_chat_service_auth(&client, &app_jwt).await; 696 + assert_eq!( 697 + status, 698 + StatusCode::OK, 699 + "Privileged app password should access chat methods" 700 + ); 701 + } 702 + 703 + #[tokio::test] 704 + async fn test_app_password_no_privileged_field_allows_chat() { 705 + let client = client(); 706 + let (did, jwt) = setup_new_user("appscope-full").await; 707 + let (app_jwt, create_body) = create_app_password_session( 708 + &client, 709 + &did, 710 + &jwt, 711 + "full-access", 712 + json!({ "name": "FullApp" }), 713 + ) 714 + .await; 715 + assert_eq!( 716 + create_body["scopes"].as_str().unwrap(), 717 + "transition:generic transition:chat.bsky", 718 + "App password without privileged field should default to full access" 719 + ); 720 + let status = try_chat_service_auth(&client, &app_jwt).await; 721 + assert_eq!( 722 + status, 723 + StatusCode::OK, 724 + "Full-access app password should access chat methods" 725 + ); 726 + } 727 + 728 + #[tokio::test] 729 + async fn test_app_password_explicit_scopes_respected() { 730 + let client = client(); 731 + let (did, jwt) = setup_new_user("appscope-explicit").await; 732 + let (app_jwt, create_body) = create_app_password_session( 733 + &client, 734 + &did, 735 + &jwt, 736 + "explicit-scopes", 737 + json!({ "name": "ScopedApp", "scopes": "transition:generic" }), 738 + ) 739 + .await; 740 + assert_eq!( 741 + create_body["scopes"].as_str().unwrap(), 742 + "transition:generic", 743 + "Explicit scopes should be stored as-is" 744 + ); 745 + let status = try_chat_service_auth(&client, &app_jwt).await; 746 + assert_eq!( 747 + status, 748 + StatusCode::FORBIDDEN, 749 + "App password with only transition:generic should not access chat" 750 + ); 751 + }
+28 -3
crates/tranquil-scopes/src/permissions.rs
··· 157 157 } 158 158 159 159 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { 160 - if self.has_transition_generic { 161 - return Ok(()); 160 + if lxm.starts_with("chat.bsky.") { 161 + if self.has_transition_chat { 162 + return Ok(()); 163 + } 164 + if self.has_transition_generic && !self.has_transition_chat { 165 + return Err(ScopeError::InsufficientScope { 166 + required: "transition:chat.bsky".to_string(), 167 + message: format!("Chat access requires transition:chat.bsky scope to call {}", lxm), 168 + }); 169 + } 162 170 } 163 171 164 - if lxm.starts_with("chat.bsky.") && self.has_transition_chat { 172 + if self.has_transition_generic { 165 173 return Ok(()); 166 174 } 167 175 ··· 345 353 let perms = ScopePermissions::from_scope_string(Some("transition:generic")); 346 354 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 347 355 assert!(perms.allows_blob("image/png")); 356 + } 357 + 358 + #[test] 359 + fn test_transition_generic_without_chat_blocks_chat() { 360 + let perms = ScopePermissions::from_scope_string(Some("transition:generic")); 361 + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 362 + assert!(!perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.listConvos")); 363 + assert!(!perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages")); 364 + } 365 + 366 + #[test] 367 + fn test_transition_generic_with_chat_allows_chat() { 368 + let perms = 369 + ScopePermissions::from_scope_string(Some("transition:generic transition:chat.bsky")); 370 + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 371 + assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.listConvos")); 372 + assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages")); 348 373 } 349 374 350 375 #[test]