mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-26 20:24:15 +00:00
1056 lines
36 KiB
Rust
1056 lines
36 KiB
Rust
mod common;
|
|
mod helpers;
|
|
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use chrono::Utc;
|
|
use common::{base_url, client};
|
|
use helpers::verify_new_account;
|
|
use reqwest::StatusCode;
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn generate_pkce() -> (String, String) {
|
|
let verifier_bytes: [u8; 32] = rand::random();
|
|
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(code_verifier.as_bytes());
|
|
let hash = hasher.finalize();
|
|
let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
|
|
(code_verifier, code_challenge)
|
|
}
|
|
|
|
async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
|
|
let mock_server = MockServer::start().await;
|
|
let client_id = mock_server.uri();
|
|
let metadata = json!({
|
|
"client_id": client_id,
|
|
"client_name": "Test OAuth Client",
|
|
"redirect_uris": [redirect_uri],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
"dpop_bound_access_tokens": false
|
|
});
|
|
Mock::given(method("GET"))
|
|
.and(path("/"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
|
|
.mount(&mock_server)
|
|
.await;
|
|
mock_server
|
|
}
|
|
|
|
struct OAuthSession {
|
|
access_token: String,
|
|
refresh_token: String,
|
|
did: String,
|
|
client_id: String,
|
|
}
|
|
|
|
async fn create_user_and_oauth_session(
|
|
handle_prefix: &str,
|
|
redirect_uri: &str,
|
|
) -> (OAuthSession, MockServer) {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("{}-{}", handle_prefix, ts);
|
|
let email = format!("{}-{}@example.com", handle_prefix, ts);
|
|
let password = format!("{}Pass123!", handle_prefix);
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("Account creation failed");
|
|
assert_eq!(create_res.status(), StatusCode::OK);
|
|
let account: Value = create_res.json().await.unwrap();
|
|
let user_did = account["did"].as_str().unwrap().to_string();
|
|
let _ = verify_new_account(&http_client, &user_did).await;
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
let par_res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
("scope", "atproto"),
|
|
])
|
|
.send()
|
|
.await
|
|
.expect("PAR failed");
|
|
assert!(
|
|
par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
|
|
"PAR should succeed with 200 or 201, got {}",
|
|
par_res.status()
|
|
);
|
|
let par_body: Value = par_res.json().await.unwrap();
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
let auth_res = http_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({
|
|
"request_uri": request_uri,
|
|
"username": &handle,
|
|
"password": &password,
|
|
"remember_device": false
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("Authorize failed");
|
|
assert_eq!(
|
|
auth_res.status(),
|
|
StatusCode::OK,
|
|
"Authorize should return OK"
|
|
);
|
|
let auth_body: Value = auth_res.json().await.unwrap();
|
|
let mut location = auth_body["redirect_uri"]
|
|
.as_str()
|
|
.expect("Expected redirect_uri")
|
|
.to_string();
|
|
if location.contains("/oauth/consent") {
|
|
let consent_res = http_client
|
|
.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.expect("Consent request failed");
|
|
assert_eq!(
|
|
consent_res.status(),
|
|
StatusCode::OK,
|
|
"Consent should succeed"
|
|
);
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
location = consent_body["redirect_uri"]
|
|
.as_str()
|
|
.expect("Expected redirect_uri from consent")
|
|
.to_string();
|
|
}
|
|
let code = location
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap();
|
|
let token_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.expect("Token request failed");
|
|
assert_eq!(token_res.status(), StatusCode::OK);
|
|
let token_body: Value = token_res.json().await.unwrap();
|
|
let session = OAuthSession {
|
|
access_token: token_body["access_token"].as_str().unwrap().to_string(),
|
|
refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(),
|
|
did: user_did,
|
|
client_id,
|
|
};
|
|
(session, mock_client)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_token_can_create_and_read_records() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-records", "https://example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let post_text = "Hello from OAuth! This post was created with an OAuth access token.";
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": post_text,
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("createRecord failed");
|
|
assert_eq!(
|
|
create_res.status(),
|
|
StatusCode::OK,
|
|
"Should create record with OAuth token"
|
|
);
|
|
let create_body: Value = create_res.json().await.unwrap();
|
|
let uri = create_body["uri"].as_str().unwrap();
|
|
let rkey = uri.split('/').last().unwrap();
|
|
let get_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.query(&[
|
|
("repo", session.did.as_str()),
|
|
("collection", collection),
|
|
("rkey", rkey),
|
|
])
|
|
.send()
|
|
.await
|
|
.expect("getRecord failed");
|
|
assert_eq!(
|
|
get_res.status(),
|
|
StatusCode::OK,
|
|
"Should read record with OAuth token"
|
|
);
|
|
let get_body: Value = get_res.json().await.unwrap();
|
|
assert_eq!(get_body["value"]["text"], post_text);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_token_can_upload_blob() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-blob", "https://example.com/callback").await;
|
|
let blob_data = b"This is test blob data uploaded via OAuth";
|
|
let upload_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url))
|
|
.bearer_auth(&session.access_token)
|
|
.header("Content-Type", "text/plain")
|
|
.body(blob_data.to_vec())
|
|
.send()
|
|
.await
|
|
.expect("uploadBlob failed");
|
|
assert_eq!(
|
|
upload_res.status(),
|
|
StatusCode::OK,
|
|
"Should upload blob with OAuth token"
|
|
);
|
|
let upload_body: Value = upload_res.json().await.unwrap();
|
|
assert!(upload_body["blob"]["ref"]["$link"].is_string());
|
|
assert_eq!(upload_body["blob"]["mimeType"], "text/plain");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_token_can_describe_repo() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-describe", "https://example.com/callback").await;
|
|
let describe_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.describeRepo", url))
|
|
.bearer_auth(&session.access_token)
|
|
.query(&[("repo", session.did.as_str())])
|
|
.send()
|
|
.await
|
|
.expect("describeRepo failed");
|
|
assert_eq!(
|
|
describe_res.status(),
|
|
StatusCode::OK,
|
|
"Should describe repo with OAuth token"
|
|
);
|
|
let describe_body: Value = describe_res.json().await.unwrap();
|
|
assert_eq!(describe_body["did"], session.did);
|
|
assert!(describe_body["handle"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_full_post_lifecycle_create_edit_delete() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-lifecycle", "https://example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let original_text = "Original post content";
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": original_text,
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(create_res.status(), StatusCode::OK);
|
|
let create_body: Value = create_res.json().await.unwrap();
|
|
let uri = create_body["uri"].as_str().unwrap();
|
|
let rkey = uri.split('/').last().unwrap();
|
|
let updated_text = "Updated post content via OAuth putRecord";
|
|
let put_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"rkey": rkey,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": updated_text,
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
put_res.status(),
|
|
StatusCode::OK,
|
|
"Should update record with OAuth token"
|
|
);
|
|
let get_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.query(&[
|
|
("repo", session.did.as_str()),
|
|
("collection", collection),
|
|
("rkey", rkey),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let get_body: Value = get_res.json().await.unwrap();
|
|
assert_eq!(
|
|
get_body["value"]["text"], updated_text,
|
|
"Record should have updated text"
|
|
);
|
|
let delete_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"rkey": rkey
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
delete_res.status(),
|
|
StatusCode::OK,
|
|
"Should delete record with OAuth token"
|
|
);
|
|
let get_deleted_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.query(&[
|
|
("repo", session.did.as_str()),
|
|
("collection", collection),
|
|
("rkey", rkey),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
get_deleted_res.status() == StatusCode::BAD_REQUEST
|
|
|| get_deleted_res.status() == StatusCode::NOT_FOUND,
|
|
"Deleted record should not be found, got {}",
|
|
get_deleted_res.status()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_batch_operations_apply_writes() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-batch", "https://example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let now = Utc::now().to_rfc3339();
|
|
let apply_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"writes": [
|
|
{
|
|
"$type": "com.atproto.repo.applyWrites#create",
|
|
"collection": collection,
|
|
"rkey": "batch1",
|
|
"value": {
|
|
"$type": collection,
|
|
"text": "Batch post 1",
|
|
"createdAt": now
|
|
}
|
|
},
|
|
{
|
|
"$type": "com.atproto.repo.applyWrites#create",
|
|
"collection": collection,
|
|
"rkey": "batch2",
|
|
"value": {
|
|
"$type": collection,
|
|
"text": "Batch post 2",
|
|
"createdAt": now
|
|
}
|
|
},
|
|
{
|
|
"$type": "com.atproto.repo.applyWrites#create",
|
|
"collection": collection,
|
|
"rkey": "batch3",
|
|
"value": {
|
|
"$type": collection,
|
|
"text": "Batch post 3",
|
|
"createdAt": now
|
|
}
|
|
}
|
|
]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
apply_res.status(),
|
|
StatusCode::OK,
|
|
"Should apply batch writes with OAuth token"
|
|
);
|
|
let list_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(&session.access_token)
|
|
.query(&[("repo", session.did.as_str()), ("collection", collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(list_res.status(), StatusCode::OK);
|
|
let list_body: Value = list_res.json().await.unwrap();
|
|
let records = list_body["records"].as_array().unwrap();
|
|
assert!(
|
|
records.len() >= 3,
|
|
"Should have at least 3 records from batch"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_token_refresh_maintains_access() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-refresh-access", "https://example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Post before refresh",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
create_res.status(),
|
|
StatusCode::OK,
|
|
"Original token should work"
|
|
);
|
|
let refresh_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", &session.refresh_token),
|
|
("client_id", &session.client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refresh_res.status(), StatusCode::OK);
|
|
let refresh_body: Value = refresh_res.json().await.unwrap();
|
|
let new_access_token = refresh_body["access_token"].as_str().unwrap();
|
|
assert_ne!(
|
|
new_access_token, session.access_token,
|
|
"New token should be different"
|
|
);
|
|
let create_res2 = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(new_access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Post after refresh with new token",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
create_res2.status(),
|
|
StatusCode::OK,
|
|
"New token should work for creating records"
|
|
);
|
|
let list_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(new_access_token)
|
|
.query(&[("repo", session.did.as_str()), ("collection", collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
list_res.status(),
|
|
StatusCode::OK,
|
|
"New token should work for listing records"
|
|
);
|
|
let list_body: Value = list_res.json().await.unwrap();
|
|
let records = list_body["records"].as_array().unwrap();
|
|
assert_eq!(records.len(), 2, "Should have both posts");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_revoked_token_cannot_access_resources() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-revoke-access", "https://example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Post before revocation",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
create_res.status(),
|
|
StatusCode::OK,
|
|
"Token should work before revocation"
|
|
);
|
|
let revoke_res = http_client
|
|
.post(format!("{}/oauth/revoke", url))
|
|
.form(&[("token", session.refresh_token.as_str())])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
revoke_res.status(),
|
|
StatusCode::OK,
|
|
"Revocation should succeed"
|
|
);
|
|
let refresh_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", &session.refresh_token),
|
|
("client_id", &session.client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
refresh_res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Revoked refresh token should not work"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_multiple_clients_same_user() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("multi-client-{}", ts);
|
|
let email = format!("multi-client-{}@example.com", ts);
|
|
let password = "MultiClient123!";
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(create_res.status(), StatusCode::OK);
|
|
let account: Value = create_res.json().await.unwrap();
|
|
let user_did = account["did"].as_str().unwrap();
|
|
let _ = verify_new_account(&http_client, user_did).await;
|
|
let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await;
|
|
let client1_id = mock_client1.uri();
|
|
let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await;
|
|
let client2_id = mock_client2.uri();
|
|
let (verifier1, challenge1) = generate_pkce();
|
|
let par_res1 = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client1_id),
|
|
("redirect_uri", "https://client1.example.com/callback"),
|
|
("code_challenge", &challenge1),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let par_body1: Value = par_res1.json().await.unwrap();
|
|
let request_uri1 = par_body1["request_uri"].as_str().unwrap();
|
|
let auth_res1 = http_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({
|
|
"request_uri": request_uri1,
|
|
"username": &handle,
|
|
"password": password,
|
|
"remember_device": false
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(auth_res1.status(), StatusCode::OK);
|
|
let auth_body1: Value = auth_res1.json().await.unwrap();
|
|
let mut location1 = auth_body1["redirect_uri"].as_str().unwrap().to_string();
|
|
if location1.contains("/oauth/consent") {
|
|
let consent_res = http_client
|
|
.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.unwrap();
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
|
}
|
|
let code1 = location1
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap();
|
|
let token_res1 = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code1),
|
|
("redirect_uri", "https://client1.example.com/callback"),
|
|
("code_verifier", &verifier1),
|
|
("client_id", &client1_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let token_body1: Value = token_res1.json().await.unwrap();
|
|
let token1 = token_body1["access_token"].as_str().unwrap();
|
|
let (verifier2, challenge2) = generate_pkce();
|
|
let par_res2 = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client2_id),
|
|
("redirect_uri", "https://client2.example.com/callback"),
|
|
("code_challenge", &challenge2),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let par_body2: Value = par_res2.json().await.unwrap();
|
|
let request_uri2 = par_body2["request_uri"].as_str().unwrap();
|
|
let auth_res2 = http_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({
|
|
"request_uri": request_uri2,
|
|
"username": &handle,
|
|
"password": password,
|
|
"remember_device": false
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(auth_res2.status(), StatusCode::OK);
|
|
let auth_body2: Value = auth_res2.json().await.unwrap();
|
|
let mut location2 = auth_body2["redirect_uri"].as_str().unwrap().to_string();
|
|
if location2.contains("/oauth/consent") {
|
|
let consent_res = http_client
|
|
.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.unwrap();
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
|
}
|
|
let code2 = location2
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap();
|
|
let token_res2 = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code2),
|
|
("redirect_uri", "https://client2.example.com/callback"),
|
|
("code_verifier", &verifier2),
|
|
("client_id", &client2_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let token_body2: Value = token_res2.json().await.unwrap();
|
|
let token2 = token_body2["access_token"].as_str().unwrap();
|
|
assert_ne!(
|
|
token1, token2,
|
|
"Different clients should get different tokens"
|
|
);
|
|
let collection = "app.bsky.feed.post";
|
|
let create_res1 = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(token1)
|
|
.json(&json!({
|
|
"repo": user_did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Post from client 1",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
create_res1.status(),
|
|
StatusCode::OK,
|
|
"Client 1 token should work"
|
|
);
|
|
let create_res2 = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(token2)
|
|
.json(&json!({
|
|
"repo": user_did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Post from client 2",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
create_res2.status(),
|
|
StatusCode::OK,
|
|
"Client 2 token should work"
|
|
);
|
|
let list_res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(token1)
|
|
.query(&[("repo", user_did), ("collection", collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let list_body: Value = list_res.json().await.unwrap();
|
|
let records = list_body["records"].as_array().unwrap();
|
|
assert_eq!(
|
|
records.len(),
|
|
2,
|
|
"Both posts should be visible to either client"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_social_interactions_follow_like_repost() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (alice, _mock_alice) =
|
|
create_user_and_oauth_session("alice-social", "https://alice-app.example.com/callback")
|
|
.await;
|
|
let (bob, _mock_bob) =
|
|
create_user_and_oauth_session("bob-social", "https://bob-app.example.com/callback").await;
|
|
let post_collection = "app.bsky.feed.post";
|
|
let post_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&alice.access_token)
|
|
.json(&json!({
|
|
"repo": alice.did,
|
|
"collection": post_collection,
|
|
"record": {
|
|
"$type": post_collection,
|
|
"text": "Hello from Alice! Looking for friends.",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(post_res.status(), StatusCode::OK);
|
|
let post_body: Value = post_res.json().await.unwrap();
|
|
let post_uri = post_body["uri"].as_str().unwrap();
|
|
let post_cid = post_body["cid"].as_str().unwrap();
|
|
let follow_collection = "app.bsky.graph.follow";
|
|
let follow_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.json(&json!({
|
|
"repo": bob.did,
|
|
"collection": follow_collection,
|
|
"record": {
|
|
"$type": follow_collection,
|
|
"subject": alice.did,
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
follow_res.status(),
|
|
StatusCode::OK,
|
|
"Bob should be able to follow Alice via OAuth"
|
|
);
|
|
let like_collection = "app.bsky.feed.like";
|
|
let like_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.json(&json!({
|
|
"repo": bob.did,
|
|
"collection": like_collection,
|
|
"record": {
|
|
"$type": like_collection,
|
|
"subject": {
|
|
"uri": post_uri,
|
|
"cid": post_cid
|
|
},
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
like_res.status(),
|
|
StatusCode::OK,
|
|
"Bob should be able to like Alice's post via OAuth"
|
|
);
|
|
let repost_collection = "app.bsky.feed.repost";
|
|
let repost_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.json(&json!({
|
|
"repo": bob.did,
|
|
"collection": repost_collection,
|
|
"record": {
|
|
"$type": repost_collection,
|
|
"subject": {
|
|
"uri": post_uri,
|
|
"cid": post_cid
|
|
},
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
repost_res.status(),
|
|
StatusCode::OK,
|
|
"Bob should be able to repost Alice's post via OAuth"
|
|
);
|
|
let bob_follows = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.query(&[
|
|
("repo", bob.did.as_str()),
|
|
("collection", follow_collection),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let follows_body: Value = bob_follows.json().await.unwrap();
|
|
let follows = follows_body["records"].as_array().unwrap();
|
|
assert_eq!(follows.len(), 1, "Bob should have 1 follow");
|
|
assert_eq!(follows[0]["value"]["subject"], alice.did);
|
|
let bob_likes = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.query(&[("repo", bob.did.as_str()), ("collection", like_collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let likes_body: Value = bob_likes.json().await.unwrap();
|
|
let likes = likes_body["records"].as_array().unwrap();
|
|
assert_eq!(likes.len(), 1, "Bob should have 1 like");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_cannot_modify_other_users_repo() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (alice, _mock_alice) =
|
|
create_user_and_oauth_session("alice-boundary", "https://alice.example.com/callback").await;
|
|
let (bob, _mock_bob) =
|
|
create_user_and_oauth_session("bob-boundary", "https://bob.example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let malicious_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.json(&json!({
|
|
"repo": alice.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Bob trying to post as Alice!",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(
|
|
malicious_res.status(),
|
|
StatusCode::OK,
|
|
"Bob should NOT be able to create records in Alice's repo"
|
|
);
|
|
let alice_posts = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(&alice.access_token)
|
|
.query(&[("repo", alice.did.as_str()), ("collection", collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let posts_body: Value = alice_posts.json().await.unwrap();
|
|
let posts = posts_body["records"].as_array().unwrap();
|
|
assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_session_isolation_between_users() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (alice, _mock_alice) =
|
|
create_user_and_oauth_session("alice-isolation", "https://alice.example.com/callback")
|
|
.await;
|
|
let (bob, _mock_bob) =
|
|
create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
let alice_post = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&alice.access_token)
|
|
.json(&json!({
|
|
"repo": alice.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Alice's private thoughts",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(alice_post.status(), StatusCode::OK);
|
|
let bob_post = http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.json(&json!({
|
|
"repo": bob.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Bob's different thoughts",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bob_post.status(), StatusCode::OK);
|
|
let alice_list = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(&alice.access_token)
|
|
.query(&[("repo", alice.did.as_str()), ("collection", collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let alice_records: Value = alice_list.json().await.unwrap();
|
|
let alice_posts = alice_records["records"].as_array().unwrap();
|
|
assert_eq!(alice_posts.len(), 1);
|
|
assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts");
|
|
let bob_list = http_client
|
|
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
|
.bearer_auth(&bob.access_token)
|
|
.query(&[("repo", bob.did.as_str()), ("collection", collection)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let bob_records: Value = bob_list.json().await.unwrap();
|
|
let bob_posts = bob_records["records"].as_array().unwrap();
|
|
assert_eq!(bob_posts.len(), 1);
|
|
assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_token_works_with_sync_endpoints() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (session, _mock) =
|
|
create_user_and_oauth_session("oauth-sync", "https://example.com/callback").await;
|
|
let collection = "app.bsky.feed.post";
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
|
.bearer_auth(&session.access_token)
|
|
.json(&json!({
|
|
"repo": session.did,
|
|
"collection": collection,
|
|
"record": {
|
|
"$type": collection,
|
|
"text": "Post to sync",
|
|
"createdAt": Utc::now().to_rfc3339()
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let latest_commit = http_client
|
|
.get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url))
|
|
.query(&[("did", session.did.as_str())])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(latest_commit.status(), StatusCode::OK);
|
|
let commit_body: Value = latest_commit.json().await.unwrap();
|
|
assert!(commit_body["cid"].is_string());
|
|
assert!(commit_body["rev"].is_string());
|
|
let repo_status = http_client
|
|
.get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url))
|
|
.query(&[("did", session.did.as_str())])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(repo_status.status(), StatusCode::OK);
|
|
let status_body: Value = repo_status.json().await.unwrap();
|
|
assert_eq!(status_body["did"], session.did);
|
|
assert!(status_body["active"].as_bool().unwrap());
|
|
}
|