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

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-04-12 15:56:17 +03:00
parent 34f050a1f0
commit 255c7135f9
6 changed files with 203 additions and 10 deletions
+13 -4
View File
@@ -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,
}
})
}
@@ -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();
@@ -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,
};
@@ -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,
};
@@ -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"
);
}
+28 -3
View File
@@ -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"));