this repo has no description
1mod common; 2mod helpers; 3 4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5use chrono::Utc; 6use common::{base_url, client}; 7use reqwest::{redirect, StatusCode}; 8use serde_json::{json, Value}; 9use sha2::{Digest, Sha256}; 10use wiremock::{Mock, MockServer, ResponseTemplate}; 11use wiremock::matchers::{method, path}; 12 13fn generate_pkce() -> (String, String) { 14 let verifier_bytes: [u8; 32] = rand::random(); 15 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 16 17 let mut hasher = Sha256::new(); 18 hasher.update(code_verifier.as_bytes()); 19 let hash = hasher.finalize(); 20 let code_challenge = URL_SAFE_NO_PAD.encode(&hash); 21 22 (code_verifier, code_challenge) 23} 24 25fn no_redirect_client() -> reqwest::Client { 26 reqwest::Client::builder() 27 .redirect(redirect::Policy::none()) 28 .build() 29 .unwrap() 30} 31 32async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 33 let mock_server = MockServer::start().await; 34 35 let client_id = mock_server.uri(); 36 let metadata = json!({ 37 "client_id": client_id, 38 "client_name": "Test OAuth Client", 39 "redirect_uris": [redirect_uri], 40 "grant_types": ["authorization_code", "refresh_token"], 41 "response_types": ["code"], 42 "token_endpoint_auth_method": "none", 43 "dpop_bound_access_tokens": false 44 }); 45 46 Mock::given(method("GET")) 47 .and(path("/")) 48 .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 49 .mount(&mock_server) 50 .await; 51 52 mock_server 53} 54 55struct OAuthSession { 56 access_token: String, 57 refresh_token: String, 58 did: String, 59 client_id: String, 60} 61 62async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) -> (OAuthSession, MockServer) { 63 let url = base_url().await; 64 let http_client = client(); 65 66 let ts = Utc::now().timestamp_millis(); 67 let handle = format!("{}-{}", handle_prefix, ts); 68 let email = format!("{}-{}@example.com", handle_prefix, ts); 69 let password = format!("{}-password", handle_prefix); 70 71 let create_res = http_client 72 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 73 .json(&json!({ 74 "handle": handle, 75 "email": email, 76 "password": password 77 })) 78 .send() 79 .await 80 .expect("Account creation failed"); 81 82 assert_eq!(create_res.status(), StatusCode::OK); 83 let account: Value = create_res.json().await.unwrap(); 84 let user_did = account["did"].as_str().unwrap().to_string(); 85 86 let mock_client = setup_mock_client_metadata(redirect_uri).await; 87 let client_id = mock_client.uri(); 88 89 let (code_verifier, code_challenge) = generate_pkce(); 90 91 let par_res = http_client 92 .post(format!("{}/oauth/par", url)) 93 .form(&[ 94 ("response_type", "code"), 95 ("client_id", &client_id), 96 ("redirect_uri", redirect_uri), 97 ("code_challenge", &code_challenge), 98 ("code_challenge_method", "S256"), 99 ("scope", "atproto"), 100 ]) 101 .send() 102 .await 103 .expect("PAR failed"); 104 105 assert_eq!(par_res.status(), StatusCode::OK); 106 let par_body: Value = par_res.json().await.unwrap(); 107 let request_uri = par_body["request_uri"].as_str().unwrap(); 108 109 let auth_client = no_redirect_client(); 110 let auth_res = auth_client 111 .post(format!("{}/oauth/authorize", url)) 112 .form(&[ 113 ("request_uri", request_uri), 114 ("username", &handle), 115 ("password", &password), 116 ("remember_device", "false"), 117 ]) 118 .send() 119 .await 120 .expect("Authorize failed"); 121 122 let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 123 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 124 125 let token_res = http_client 126 .post(format!("{}/oauth/token", url)) 127 .form(&[ 128 ("grant_type", "authorization_code"), 129 ("code", code), 130 ("redirect_uri", redirect_uri), 131 ("code_verifier", &code_verifier), 132 ("client_id", &client_id), 133 ]) 134 .send() 135 .await 136 .expect("Token request failed"); 137 138 assert_eq!(token_res.status(), StatusCode::OK); 139 let token_body: Value = token_res.json().await.unwrap(); 140 141 let session = OAuthSession { 142 access_token: token_body["access_token"].as_str().unwrap().to_string(), 143 refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), 144 did: user_did, 145 client_id, 146 }; 147 148 (session, mock_client) 149} 150 151#[tokio::test] 152async fn test_oauth_token_can_create_and_read_records() { 153 let url = base_url().await; 154 let http_client = client(); 155 156 let (session, _mock) = create_user_and_oauth_session( 157 "oauth-records", 158 "https://example.com/callback" 159 ).await; 160 161 let collection = "app.bsky.feed.post"; 162 let post_text = "Hello from OAuth! This post was created with an OAuth access token."; 163 164 let create_res = http_client 165 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 166 .bearer_auth(&session.access_token) 167 .json(&json!({ 168 "repo": session.did, 169 "collection": collection, 170 "record": { 171 "$type": collection, 172 "text": post_text, 173 "createdAt": Utc::now().to_rfc3339() 174 } 175 })) 176 .send() 177 .await 178 .expect("createRecord failed"); 179 180 assert_eq!(create_res.status(), StatusCode::OK, "Should create record with OAuth token"); 181 182 let create_body: Value = create_res.json().await.unwrap(); 183 let uri = create_body["uri"].as_str().unwrap(); 184 let rkey = uri.split('/').last().unwrap(); 185 186 let get_res = http_client 187 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) 188 .bearer_auth(&session.access_token) 189 .query(&[ 190 ("repo", session.did.as_str()), 191 ("collection", collection), 192 ("rkey", rkey), 193 ]) 194 .send() 195 .await 196 .expect("getRecord failed"); 197 198 assert_eq!(get_res.status(), StatusCode::OK, "Should read record with OAuth token"); 199 200 let get_body: Value = get_res.json().await.unwrap(); 201 assert_eq!(get_body["value"]["text"], post_text); 202} 203 204#[tokio::test] 205async fn test_oauth_token_can_upload_blob() { 206 let url = base_url().await; 207 let http_client = client(); 208 209 let (session, _mock) = create_user_and_oauth_session( 210 "oauth-blob", 211 "https://example.com/callback" 212 ).await; 213 214 let blob_data = b"This is test blob data uploaded via OAuth"; 215 216 let upload_res = http_client 217 .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url)) 218 .bearer_auth(&session.access_token) 219 .header("Content-Type", "text/plain") 220 .body(blob_data.to_vec()) 221 .send() 222 .await 223 .expect("uploadBlob failed"); 224 225 assert_eq!(upload_res.status(), StatusCode::OK, "Should upload blob with OAuth token"); 226 227 let upload_body: Value = upload_res.json().await.unwrap(); 228 assert!(upload_body["blob"]["ref"]["$link"].is_string()); 229 assert_eq!(upload_body["blob"]["mimeType"], "text/plain"); 230} 231 232#[tokio::test] 233async fn test_oauth_token_can_describe_repo() { 234 let url = base_url().await; 235 let http_client = client(); 236 237 let (session, _mock) = create_user_and_oauth_session( 238 "oauth-describe", 239 "https://example.com/callback" 240 ).await; 241 242 let describe_res = http_client 243 .get(format!("{}/xrpc/com.atproto.repo.describeRepo", url)) 244 .bearer_auth(&session.access_token) 245 .query(&[("repo", session.did.as_str())]) 246 .send() 247 .await 248 .expect("describeRepo failed"); 249 250 assert_eq!(describe_res.status(), StatusCode::OK, "Should describe repo with OAuth token"); 251 252 let describe_body: Value = describe_res.json().await.unwrap(); 253 assert_eq!(describe_body["did"], session.did); 254 assert!(describe_body["handle"].is_string()); 255} 256 257#[tokio::test] 258async fn test_oauth_full_post_lifecycle_create_edit_delete() { 259 let url = base_url().await; 260 let http_client = client(); 261 262 let (session, _mock) = create_user_and_oauth_session( 263 "oauth-lifecycle", 264 "https://example.com/callback" 265 ).await; 266 267 let collection = "app.bsky.feed.post"; 268 let original_text = "Original post content"; 269 270 let create_res = http_client 271 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 272 .bearer_auth(&session.access_token) 273 .json(&json!({ 274 "repo": session.did, 275 "collection": collection, 276 "record": { 277 "$type": collection, 278 "text": original_text, 279 "createdAt": Utc::now().to_rfc3339() 280 } 281 })) 282 .send() 283 .await 284 .unwrap(); 285 286 assert_eq!(create_res.status(), StatusCode::OK); 287 let create_body: Value = create_res.json().await.unwrap(); 288 let uri = create_body["uri"].as_str().unwrap(); 289 let rkey = uri.split('/').last().unwrap(); 290 291 let updated_text = "Updated post content via OAuth putRecord"; 292 293 let put_res = http_client 294 .post(format!("{}/xrpc/com.atproto.repo.putRecord", url)) 295 .bearer_auth(&session.access_token) 296 .json(&json!({ 297 "repo": session.did, 298 "collection": collection, 299 "rkey": rkey, 300 "record": { 301 "$type": collection, 302 "text": updated_text, 303 "createdAt": Utc::now().to_rfc3339() 304 } 305 })) 306 .send() 307 .await 308 .unwrap(); 309 310 assert_eq!(put_res.status(), StatusCode::OK, "Should update record with OAuth token"); 311 312 let get_res = http_client 313 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) 314 .bearer_auth(&session.access_token) 315 .query(&[ 316 ("repo", session.did.as_str()), 317 ("collection", collection), 318 ("rkey", rkey), 319 ]) 320 .send() 321 .await 322 .unwrap(); 323 324 let get_body: Value = get_res.json().await.unwrap(); 325 assert_eq!(get_body["value"]["text"], updated_text, "Record should have updated text"); 326 327 let delete_res = http_client 328 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 329 .bearer_auth(&session.access_token) 330 .json(&json!({ 331 "repo": session.did, 332 "collection": collection, 333 "rkey": rkey 334 })) 335 .send() 336 .await 337 .unwrap(); 338 339 assert_eq!(delete_res.status(), StatusCode::OK, "Should delete record with OAuth token"); 340 341 let get_deleted_res = http_client 342 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url)) 343 .bearer_auth(&session.access_token) 344 .query(&[ 345 ("repo", session.did.as_str()), 346 ("collection", collection), 347 ("rkey", rkey), 348 ]) 349 .send() 350 .await 351 .unwrap(); 352 353 assert!( 354 get_deleted_res.status() == StatusCode::BAD_REQUEST || get_deleted_res.status() == StatusCode::NOT_FOUND, 355 "Deleted record should not be found, got {}", 356 get_deleted_res.status() 357 ); 358} 359 360#[tokio::test] 361async fn test_oauth_batch_operations_apply_writes() { 362 let url = base_url().await; 363 let http_client = client(); 364 365 let (session, _mock) = create_user_and_oauth_session( 366 "oauth-batch", 367 "https://example.com/callback" 368 ).await; 369 370 let collection = "app.bsky.feed.post"; 371 let now = Utc::now().to_rfc3339(); 372 373 let apply_res = http_client 374 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url)) 375 .bearer_auth(&session.access_token) 376 .json(&json!({ 377 "repo": session.did, 378 "writes": [ 379 { 380 "$type": "com.atproto.repo.applyWrites#create", 381 "collection": collection, 382 "rkey": "batch1", 383 "value": { 384 "$type": collection, 385 "text": "Batch post 1", 386 "createdAt": now 387 } 388 }, 389 { 390 "$type": "com.atproto.repo.applyWrites#create", 391 "collection": collection, 392 "rkey": "batch2", 393 "value": { 394 "$type": collection, 395 "text": "Batch post 2", 396 "createdAt": now 397 } 398 }, 399 { 400 "$type": "com.atproto.repo.applyWrites#create", 401 "collection": collection, 402 "rkey": "batch3", 403 "value": { 404 "$type": collection, 405 "text": "Batch post 3", 406 "createdAt": now 407 } 408 } 409 ] 410 })) 411 .send() 412 .await 413 .unwrap(); 414 415 assert_eq!(apply_res.status(), StatusCode::OK, "Should apply batch writes with OAuth token"); 416 417 let list_res = http_client 418 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 419 .bearer_auth(&session.access_token) 420 .query(&[ 421 ("repo", session.did.as_str()), 422 ("collection", collection), 423 ]) 424 .send() 425 .await 426 .unwrap(); 427 428 assert_eq!(list_res.status(), StatusCode::OK); 429 let list_body: Value = list_res.json().await.unwrap(); 430 let records = list_body["records"].as_array().unwrap(); 431 assert!(records.len() >= 3, "Should have at least 3 records from batch"); 432} 433 434#[tokio::test] 435async fn test_oauth_token_refresh_maintains_access() { 436 let url = base_url().await; 437 let http_client = client(); 438 439 let (session, _mock) = create_user_and_oauth_session( 440 "oauth-refresh-access", 441 "https://example.com/callback" 442 ).await; 443 444 let collection = "app.bsky.feed.post"; 445 let create_res = http_client 446 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 447 .bearer_auth(&session.access_token) 448 .json(&json!({ 449 "repo": session.did, 450 "collection": collection, 451 "record": { 452 "$type": collection, 453 "text": "Post before refresh", 454 "createdAt": Utc::now().to_rfc3339() 455 } 456 })) 457 .send() 458 .await 459 .unwrap(); 460 461 assert_eq!(create_res.status(), StatusCode::OK, "Original token should work"); 462 463 let refresh_res = http_client 464 .post(format!("{}/oauth/token", url)) 465 .form(&[ 466 ("grant_type", "refresh_token"), 467 ("refresh_token", &session.refresh_token), 468 ("client_id", &session.client_id), 469 ]) 470 .send() 471 .await 472 .unwrap(); 473 474 assert_eq!(refresh_res.status(), StatusCode::OK); 475 let refresh_body: Value = refresh_res.json().await.unwrap(); 476 let new_access_token = refresh_body["access_token"].as_str().unwrap(); 477 478 assert_ne!(new_access_token, session.access_token, "New token should be different"); 479 480 let create_res2 = http_client 481 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 482 .bearer_auth(new_access_token) 483 .json(&json!({ 484 "repo": session.did, 485 "collection": collection, 486 "record": { 487 "$type": collection, 488 "text": "Post after refresh with new token", 489 "createdAt": Utc::now().to_rfc3339() 490 } 491 })) 492 .send() 493 .await 494 .unwrap(); 495 496 assert_eq!(create_res2.status(), StatusCode::OK, "New token should work for creating records"); 497 498 let list_res = http_client 499 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 500 .bearer_auth(new_access_token) 501 .query(&[ 502 ("repo", session.did.as_str()), 503 ("collection", collection), 504 ]) 505 .send() 506 .await 507 .unwrap(); 508 509 assert_eq!(list_res.status(), StatusCode::OK, "New token should work for listing records"); 510 let list_body: Value = list_res.json().await.unwrap(); 511 let records = list_body["records"].as_array().unwrap(); 512 assert_eq!(records.len(), 2, "Should have both posts"); 513} 514 515#[tokio::test] 516async fn test_oauth_revoked_token_cannot_access_resources() { 517 let url = base_url().await; 518 let http_client = client(); 519 520 let (session, _mock) = create_user_and_oauth_session( 521 "oauth-revoke-access", 522 "https://example.com/callback" 523 ).await; 524 525 let collection = "app.bsky.feed.post"; 526 let create_res = http_client 527 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 528 .bearer_auth(&session.access_token) 529 .json(&json!({ 530 "repo": session.did, 531 "collection": collection, 532 "record": { 533 "$type": collection, 534 "text": "Post before revocation", 535 "createdAt": Utc::now().to_rfc3339() 536 } 537 })) 538 .send() 539 .await 540 .unwrap(); 541 542 assert_eq!(create_res.status(), StatusCode::OK, "Token should work before revocation"); 543 544 let revoke_res = http_client 545 .post(format!("{}/oauth/revoke", url)) 546 .form(&[("token", session.refresh_token.as_str())]) 547 .send() 548 .await 549 .unwrap(); 550 551 assert_eq!(revoke_res.status(), StatusCode::OK, "Revocation should succeed"); 552 553 let refresh_res = http_client 554 .post(format!("{}/oauth/token", url)) 555 .form(&[ 556 ("grant_type", "refresh_token"), 557 ("refresh_token", &session.refresh_token), 558 ("client_id", &session.client_id), 559 ]) 560 .send() 561 .await 562 .unwrap(); 563 564 assert_eq!(refresh_res.status(), StatusCode::BAD_REQUEST, "Revoked refresh token should not work"); 565} 566 567#[tokio::test] 568async fn test_oauth_multiple_clients_same_user() { 569 let url = base_url().await; 570 let http_client = client(); 571 572 let ts = Utc::now().timestamp_millis(); 573 let handle = format!("multi-client-{}", ts); 574 let email = format!("multi-client-{}@example.com", ts); 575 let password = "multi-client-password"; 576 577 let create_res = http_client 578 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 579 .json(&json!({ 580 "handle": handle, 581 "email": email, 582 "password": password 583 })) 584 .send() 585 .await 586 .unwrap(); 587 588 assert_eq!(create_res.status(), StatusCode::OK); 589 let account: Value = create_res.json().await.unwrap(); 590 let user_did = account["did"].as_str().unwrap(); 591 592 let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await; 593 let client1_id = mock_client1.uri(); 594 595 let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await; 596 let client2_id = mock_client2.uri(); 597 598 let (verifier1, challenge1) = generate_pkce(); 599 let par_res1 = http_client 600 .post(format!("{}/oauth/par", url)) 601 .form(&[ 602 ("response_type", "code"), 603 ("client_id", &client1_id), 604 ("redirect_uri", "https://client1.example.com/callback"), 605 ("code_challenge", &challenge1), 606 ("code_challenge_method", "S256"), 607 ]) 608 .send() 609 .await 610 .unwrap(); 611 let par_body1: Value = par_res1.json().await.unwrap(); 612 let request_uri1 = par_body1["request_uri"].as_str().unwrap(); 613 614 let auth_client = no_redirect_client(); 615 let auth_res1 = auth_client 616 .post(format!("{}/oauth/authorize", url)) 617 .form(&[ 618 ("request_uri", request_uri1), 619 ("username", &handle), 620 ("password", password), 621 ("remember_device", "false"), 622 ]) 623 .send() 624 .await 625 .unwrap(); 626 let location1 = auth_res1.headers().get("location").unwrap().to_str().unwrap(); 627 let code1 = location1.split("code=").nth(1).unwrap().split('&').next().unwrap(); 628 629 let token_res1 = http_client 630 .post(format!("{}/oauth/token", url)) 631 .form(&[ 632 ("grant_type", "authorization_code"), 633 ("code", code1), 634 ("redirect_uri", "https://client1.example.com/callback"), 635 ("code_verifier", &verifier1), 636 ("client_id", &client1_id), 637 ]) 638 .send() 639 .await 640 .unwrap(); 641 let token_body1: Value = token_res1.json().await.unwrap(); 642 let token1 = token_body1["access_token"].as_str().unwrap(); 643 644 let (verifier2, challenge2) = generate_pkce(); 645 let par_res2 = http_client 646 .post(format!("{}/oauth/par", url)) 647 .form(&[ 648 ("response_type", "code"), 649 ("client_id", &client2_id), 650 ("redirect_uri", "https://client2.example.com/callback"), 651 ("code_challenge", &challenge2), 652 ("code_challenge_method", "S256"), 653 ]) 654 .send() 655 .await 656 .unwrap(); 657 let par_body2: Value = par_res2.json().await.unwrap(); 658 let request_uri2 = par_body2["request_uri"].as_str().unwrap(); 659 660 let auth_res2 = auth_client 661 .post(format!("{}/oauth/authorize", url)) 662 .form(&[ 663 ("request_uri", request_uri2), 664 ("username", &handle), 665 ("password", password), 666 ("remember_device", "false"), 667 ]) 668 .send() 669 .await 670 .unwrap(); 671 let location2 = auth_res2.headers().get("location").unwrap().to_str().unwrap(); 672 let code2 = location2.split("code=").nth(1).unwrap().split('&').next().unwrap(); 673 674 let token_res2 = http_client 675 .post(format!("{}/oauth/token", url)) 676 .form(&[ 677 ("grant_type", "authorization_code"), 678 ("code", code2), 679 ("redirect_uri", "https://client2.example.com/callback"), 680 ("code_verifier", &verifier2), 681 ("client_id", &client2_id), 682 ]) 683 .send() 684 .await 685 .unwrap(); 686 let token_body2: Value = token_res2.json().await.unwrap(); 687 let token2 = token_body2["access_token"].as_str().unwrap(); 688 689 assert_ne!(token1, token2, "Different clients should get different tokens"); 690 691 let collection = "app.bsky.feed.post"; 692 693 let create_res1 = http_client 694 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 695 .bearer_auth(token1) 696 .json(&json!({ 697 "repo": user_did, 698 "collection": collection, 699 "record": { 700 "$type": collection, 701 "text": "Post from client 1", 702 "createdAt": Utc::now().to_rfc3339() 703 } 704 })) 705 .send() 706 .await 707 .unwrap(); 708 709 assert_eq!(create_res1.status(), StatusCode::OK, "Client 1 token should work"); 710 711 let create_res2 = http_client 712 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 713 .bearer_auth(token2) 714 .json(&json!({ 715 "repo": user_did, 716 "collection": collection, 717 "record": { 718 "$type": collection, 719 "text": "Post from client 2", 720 "createdAt": Utc::now().to_rfc3339() 721 } 722 })) 723 .send() 724 .await 725 .unwrap(); 726 727 assert_eq!(create_res2.status(), StatusCode::OK, "Client 2 token should work"); 728 729 let list_res = http_client 730 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 731 .bearer_auth(token1) 732 .query(&[ 733 ("repo", user_did), 734 ("collection", collection), 735 ]) 736 .send() 737 .await 738 .unwrap(); 739 740 let list_body: Value = list_res.json().await.unwrap(); 741 let records = list_body["records"].as_array().unwrap(); 742 assert_eq!(records.len(), 2, "Both posts should be visible to either client"); 743} 744 745#[tokio::test] 746async fn test_oauth_social_interactions_follow_like_repost() { 747 let url = base_url().await; 748 let http_client = client(); 749 750 let (alice, _mock_alice) = create_user_and_oauth_session( 751 "alice-social", 752 "https://alice-app.example.com/callback" 753 ).await; 754 755 let (bob, _mock_bob) = create_user_and_oauth_session( 756 "bob-social", 757 "https://bob-app.example.com/callback" 758 ).await; 759 760 let post_collection = "app.bsky.feed.post"; 761 let post_res = http_client 762 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 763 .bearer_auth(&alice.access_token) 764 .json(&json!({ 765 "repo": alice.did, 766 "collection": post_collection, 767 "record": { 768 "$type": post_collection, 769 "text": "Hello from Alice! Looking for friends.", 770 "createdAt": Utc::now().to_rfc3339() 771 } 772 })) 773 .send() 774 .await 775 .unwrap(); 776 777 assert_eq!(post_res.status(), StatusCode::OK); 778 let post_body: Value = post_res.json().await.unwrap(); 779 let post_uri = post_body["uri"].as_str().unwrap(); 780 let post_cid = post_body["cid"].as_str().unwrap(); 781 782 let follow_collection = "app.bsky.graph.follow"; 783 let follow_res = http_client 784 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 785 .bearer_auth(&bob.access_token) 786 .json(&json!({ 787 "repo": bob.did, 788 "collection": follow_collection, 789 "record": { 790 "$type": follow_collection, 791 "subject": alice.did, 792 "createdAt": Utc::now().to_rfc3339() 793 } 794 })) 795 .send() 796 .await 797 .unwrap(); 798 799 assert_eq!(follow_res.status(), StatusCode::OK, "Bob should be able to follow Alice via OAuth"); 800 801 let like_collection = "app.bsky.feed.like"; 802 let like_res = http_client 803 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 804 .bearer_auth(&bob.access_token) 805 .json(&json!({ 806 "repo": bob.did, 807 "collection": like_collection, 808 "record": { 809 "$type": like_collection, 810 "subject": { 811 "uri": post_uri, 812 "cid": post_cid 813 }, 814 "createdAt": Utc::now().to_rfc3339() 815 } 816 })) 817 .send() 818 .await 819 .unwrap(); 820 821 assert_eq!(like_res.status(), StatusCode::OK, "Bob should be able to like Alice's post via OAuth"); 822 823 let repost_collection = "app.bsky.feed.repost"; 824 let repost_res = http_client 825 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 826 .bearer_auth(&bob.access_token) 827 .json(&json!({ 828 "repo": bob.did, 829 "collection": repost_collection, 830 "record": { 831 "$type": repost_collection, 832 "subject": { 833 "uri": post_uri, 834 "cid": post_cid 835 }, 836 "createdAt": Utc::now().to_rfc3339() 837 } 838 })) 839 .send() 840 .await 841 .unwrap(); 842 843 assert_eq!(repost_res.status(), StatusCode::OK, "Bob should be able to repost Alice's post via OAuth"); 844 845 let bob_follows = http_client 846 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 847 .bearer_auth(&bob.access_token) 848 .query(&[ 849 ("repo", bob.did.as_str()), 850 ("collection", follow_collection), 851 ]) 852 .send() 853 .await 854 .unwrap(); 855 856 let follows_body: Value = bob_follows.json().await.unwrap(); 857 let follows = follows_body["records"].as_array().unwrap(); 858 assert_eq!(follows.len(), 1, "Bob should have 1 follow"); 859 assert_eq!(follows[0]["value"]["subject"], alice.did); 860 861 let bob_likes = http_client 862 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 863 .bearer_auth(&bob.access_token) 864 .query(&[ 865 ("repo", bob.did.as_str()), 866 ("collection", like_collection), 867 ]) 868 .send() 869 .await 870 .unwrap(); 871 872 let likes_body: Value = bob_likes.json().await.unwrap(); 873 let likes = likes_body["records"].as_array().unwrap(); 874 assert_eq!(likes.len(), 1, "Bob should have 1 like"); 875} 876 877#[tokio::test] 878async fn test_oauth_cannot_modify_other_users_repo() { 879 let url = base_url().await; 880 let http_client = client(); 881 882 let (alice, _mock_alice) = create_user_and_oauth_session( 883 "alice-boundary", 884 "https://alice.example.com/callback" 885 ).await; 886 887 let (bob, _mock_bob) = create_user_and_oauth_session( 888 "bob-boundary", 889 "https://bob.example.com/callback" 890 ).await; 891 892 let collection = "app.bsky.feed.post"; 893 let malicious_res = http_client 894 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 895 .bearer_auth(&bob.access_token) 896 .json(&json!({ 897 "repo": alice.did, 898 "collection": collection, 899 "record": { 900 "$type": collection, 901 "text": "Bob trying to post as Alice!", 902 "createdAt": Utc::now().to_rfc3339() 903 } 904 })) 905 .send() 906 .await 907 .unwrap(); 908 909 assert_ne!( 910 malicious_res.status(), 911 StatusCode::OK, 912 "Bob should NOT be able to create records in Alice's repo" 913 ); 914 915 let alice_posts = http_client 916 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 917 .bearer_auth(&alice.access_token) 918 .query(&[ 919 ("repo", alice.did.as_str()), 920 ("collection", collection), 921 ]) 922 .send() 923 .await 924 .unwrap(); 925 926 let posts_body: Value = alice_posts.json().await.unwrap(); 927 let posts = posts_body["records"].as_array().unwrap(); 928 assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob"); 929} 930 931#[tokio::test] 932async fn test_oauth_session_isolation_between_users() { 933 let url = base_url().await; 934 let http_client = client(); 935 936 let (alice, _mock_alice) = create_user_and_oauth_session( 937 "alice-isolation", 938 "https://alice.example.com/callback" 939 ).await; 940 941 let (bob, _mock_bob) = create_user_and_oauth_session( 942 "bob-isolation", 943 "https://bob.example.com/callback" 944 ).await; 945 946 let collection = "app.bsky.feed.post"; 947 948 let alice_post = http_client 949 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 950 .bearer_auth(&alice.access_token) 951 .json(&json!({ 952 "repo": alice.did, 953 "collection": collection, 954 "record": { 955 "$type": collection, 956 "text": "Alice's private thoughts", 957 "createdAt": Utc::now().to_rfc3339() 958 } 959 })) 960 .send() 961 .await 962 .unwrap(); 963 964 assert_eq!(alice_post.status(), StatusCode::OK); 965 966 let bob_post = http_client 967 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 968 .bearer_auth(&bob.access_token) 969 .json(&json!({ 970 "repo": bob.did, 971 "collection": collection, 972 "record": { 973 "$type": collection, 974 "text": "Bob's different thoughts", 975 "createdAt": Utc::now().to_rfc3339() 976 } 977 })) 978 .send() 979 .await 980 .unwrap(); 981 982 assert_eq!(bob_post.status(), StatusCode::OK); 983 984 let alice_list = http_client 985 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 986 .bearer_auth(&alice.access_token) 987 .query(&[ 988 ("repo", alice.did.as_str()), 989 ("collection", collection), 990 ]) 991 .send() 992 .await 993 .unwrap(); 994 995 let alice_records: Value = alice_list.json().await.unwrap(); 996 let alice_posts = alice_records["records"].as_array().unwrap(); 997 assert_eq!(alice_posts.len(), 1); 998 assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts"); 999 1000 let bob_list = http_client 1001 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url)) 1002 .bearer_auth(&bob.access_token) 1003 .query(&[ 1004 ("repo", bob.did.as_str()), 1005 ("collection", collection), 1006 ]) 1007 .send() 1008 .await 1009 .unwrap(); 1010 1011 let bob_records: Value = bob_list.json().await.unwrap(); 1012 let bob_posts = bob_records["records"].as_array().unwrap(); 1013 assert_eq!(bob_posts.len(), 1); 1014 assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts"); 1015} 1016 1017#[tokio::test] 1018async fn test_oauth_token_works_with_sync_endpoints() { 1019 let url = base_url().await; 1020 let http_client = client(); 1021 1022 let (session, _mock) = create_user_and_oauth_session( 1023 "oauth-sync", 1024 "https://example.com/callback" 1025 ).await; 1026 1027 let collection = "app.bsky.feed.post"; 1028 http_client 1029 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1030 .bearer_auth(&session.access_token) 1031 .json(&json!({ 1032 "repo": session.did, 1033 "collection": collection, 1034 "record": { 1035 "$type": collection, 1036 "text": "Post to sync", 1037 "createdAt": Utc::now().to_rfc3339() 1038 } 1039 })) 1040 .send() 1041 .await 1042 .unwrap(); 1043 1044 let latest_commit = http_client 1045 .get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url)) 1046 .query(&[("did", session.did.as_str())]) 1047 .send() 1048 .await 1049 .unwrap(); 1050 1051 assert_eq!(latest_commit.status(), StatusCode::OK); 1052 let commit_body: Value = latest_commit.json().await.unwrap(); 1053 assert!(commit_body["cid"].is_string()); 1054 assert!(commit_body["rev"].is_string()); 1055 1056 let repo_status = http_client 1057 .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url)) 1058 .query(&[("did", session.did.as_str())]) 1059 .send() 1060 .await 1061 .unwrap(); 1062 1063 assert_eq!(repo_status.status(), StatusCode::OK); 1064 let status_body: Value = repo_status.json().await.unwrap(); 1065 assert_eq!(status_body["did"], session.did); 1066 assert!(status_body["active"].as_bool().unwrap()); 1067}