mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 17:26:56 +00:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user