diff --git a/crates/tranquil-api/src/common.rs b/crates/tranquil-api/src/common.rs index fd9a6ea..3ca7368 100644 --- a/crates/tranquil-api/src/common.rs +++ b/crates/tranquil-api/src/common.rs @@ -231,10 +231,19 @@ pub async fn verify_credential( app_passwords .into_iter() .find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false)) - .map(|app| CredentialMatch::AppPassword { - name: app.name, - scopes: app.scopes, - controller_did: app.created_by_controller_did, + .map(|app| { + let scopes = app.scopes.unwrap_or_else(|| { + if app.privilege.is_privileged() { + "transition:generic transition:chat.bsky".to_string() + } else { + "transition:generic".to_string() + } + }); + CredentialMatch::AppPassword { + name: app.name, + scopes: Some(scopes), + controller_did: app.created_by_controller_did, + } }) } diff --git a/crates/tranquil-api/src/server/app_password.rs b/crates/tranquil-api/src/server/app_password.rs index cf4d48d..9f187a4 100644 --- a/crates/tranquil-api/src/server/app_password.rs +++ b/crates/tranquil-api/src/server/app_password.rs @@ -132,7 +132,14 @@ pub async fn create_app_password( }; (scope_result, Some(controller.clone())) } else { - (input.scopes.clone(), None) + let scopes = match input.scopes { + Some(ref s) => s.clone(), + None => match input.privileged { + Some(false) => "transition:generic".to_string(), + _ => "transition:generic transition:chat.bsky".to_string(), + }, + }; + (Some(scopes), None) }; let password = generate_app_password(); diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index a1fc772..b678e43 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -401,7 +401,7 @@ pub async fn create_passkey_account( refresh_expires_at: refresh_expires, login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, - scope: Some("transition:generic".to_string()), + scope: Some("transition:generic transition:chat.bsky".to_string()), controller_did: None, app_password_name: None, }; diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index 5b50ef1..988ff26 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -1339,7 +1339,7 @@ pub async fn complete_registration( refresh_expires_at: refresh_meta.expires_at, login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, - scope: Some("transition:generic".to_string()), + scope: Some("transition:generic transition:chat.bsky".to_string()), controller_did: None, app_password_name: None, }; diff --git a/crates/tranquil-pds/tests/lifecycle_session.rs b/crates/tranquil-pds/tests/lifecycle_session.rs index 31b3255..8f04162 100644 --- a/crates/tranquil-pds/tests/lifecycle_session.rs +++ b/crates/tranquil-pds/tests/lifecycle_session.rs @@ -597,3 +597,155 @@ async fn test_request_account_delete() { "Token should not be expired" ); } + +async fn create_app_password_session( + client: &reqwest::Client, + did: &str, + main_jwt: &str, + name: &str, + body: Value, +) -> (String, Value) { + let base = base_url().await; + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAppPassword", + base + )) + .bearer_auth(main_jwt) + .json(&body) + .send() + .await + .expect("Failed to create app password"); + assert_eq!(create_res.status(), StatusCode::OK); + let app_pass: Value = create_res.json().await.unwrap(); + let password = app_pass["password"].as_str().unwrap().to_string(); + let scopes_response = app_pass.clone(); + let login_res = client + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) + .json(&json!({ "identifier": did, "password": password })) + .send() + .await + .expect("Failed to login with app password"); + assert_eq!(login_res.status(), StatusCode::OK, "App password login for '{}' failed", name); + let session: Value = login_res.json().await.unwrap(); + let jwt = session["accessJwt"].as_str().unwrap().to_string(); + (jwt, scopes_response) +} + +async fn try_chat_service_auth(client: &reqwest::Client, jwt: &str) -> StatusCode { + let base = base_url().await; + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.getServiceAuth", + base + )) + .bearer_auth(jwt) + .query(&[ + ("aud", "did:web:api.bsky.app"), + ("lxm", "chat.bsky.convo.listConvos"), + ]) + .send() + .await + .expect("Failed to call getServiceAuth"); + res.status() +} + +#[tokio::test] +async fn test_app_password_non_privileged_blocks_chat() { + let client = client(); + let (did, jwt) = setup_new_user("appscope-nonchat").await; + let (app_jwt, create_body) = create_app_password_session( + &client, + &did, + &jwt, + "non-privileged", + json!({ "name": "NoChatApp", "privileged": false }), + ) + .await; + assert_eq!( + create_body["scopes"].as_str().unwrap(), + "transition:generic", + "Non-privileged app password should not have chat scope" + ); + let status = try_chat_service_auth(&client, &app_jwt).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Non-privileged app password must not access chat methods" + ); +} + +#[tokio::test] +async fn test_app_password_privileged_allows_chat() { + let client = client(); + let (did, jwt) = setup_new_user("appscope-chat").await; + let (app_jwt, create_body) = create_app_password_session( + &client, + &did, + &jwt, + "privileged", + json!({ "name": "ChatApp", "privileged": true }), + ) + .await; + assert_eq!( + create_body["scopes"].as_str().unwrap(), + "transition:generic transition:chat.bsky", + "Privileged app password should have chat scope" + ); + let status = try_chat_service_auth(&client, &app_jwt).await; + assert_eq!( + status, + StatusCode::OK, + "Privileged app password should access chat methods" + ); +} + +#[tokio::test] +async fn test_app_password_no_privileged_field_allows_chat() { + let client = client(); + let (did, jwt) = setup_new_user("appscope-full").await; + let (app_jwt, create_body) = create_app_password_session( + &client, + &did, + &jwt, + "full-access", + json!({ "name": "FullApp" }), + ) + .await; + assert_eq!( + create_body["scopes"].as_str().unwrap(), + "transition:generic transition:chat.bsky", + "App password without privileged field should default to full access" + ); + let status = try_chat_service_auth(&client, &app_jwt).await; + assert_eq!( + status, + StatusCode::OK, + "Full-access app password should access chat methods" + ); +} + +#[tokio::test] +async fn test_app_password_explicit_scopes_respected() { + let client = client(); + let (did, jwt) = setup_new_user("appscope-explicit").await; + let (app_jwt, create_body) = create_app_password_session( + &client, + &did, + &jwt, + "explicit-scopes", + json!({ "name": "ScopedApp", "scopes": "transition:generic" }), + ) + .await; + assert_eq!( + create_body["scopes"].as_str().unwrap(), + "transition:generic", + "Explicit scopes should be stored as-is" + ); + let status = try_chat_service_auth(&client, &app_jwt).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "App password with only transition:generic should not access chat" + ); +} diff --git a/crates/tranquil-scopes/src/permissions.rs b/crates/tranquil-scopes/src/permissions.rs index 66029ea..7342ded 100644 --- a/crates/tranquil-scopes/src/permissions.rs +++ b/crates/tranquil-scopes/src/permissions.rs @@ -157,11 +157,19 @@ impl ScopePermissions { } pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { - if self.has_transition_generic { - return Ok(()); + if lxm.starts_with("chat.bsky.") { + if self.has_transition_chat { + return Ok(()); + } + if self.has_transition_generic && !self.has_transition_chat { + return Err(ScopeError::InsufficientScope { + required: "transition:chat.bsky".to_string(), + message: format!("Chat access requires transition:chat.bsky scope to call {}", lxm), + }); + } } - if lxm.starts_with("chat.bsky.") && self.has_transition_chat { + if self.has_transition_generic { return Ok(()); } @@ -347,6 +355,23 @@ mod tests { assert!(perms.allows_blob("image/png")); } + #[test] + fn test_transition_generic_without_chat_blocks_chat() { + let perms = ScopePermissions::from_scope_string(Some("transition:generic")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.listConvos")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages")); + } + + #[test] + fn test_transition_generic_with_chat_allows_chat() { + let perms = + ScopePermissions::from_scope_string(Some("transition:generic transition:chat.bsky")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.listConvos")); + assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages")); + } + #[test] fn test_transition_chat_only_allows_chat() { let perms = ScopePermissions::from_scope_string(Some("transition:chat.bsky"));