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 helpers::verify_new_account; 8use reqwest::StatusCode; 9use serde_json::{Value, json}; 10use sha2::{Digest, Sha256}; 11use wiremock::matchers::{method, path}; 12use wiremock::{Mock, MockServer, ResponseTemplate}; 13 14fn generate_pkce() -> (String, String) { 15 let verifier_bytes: [u8; 32] = rand::random(); 16 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 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 (code_verifier, code_challenge) 22} 23 24async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 25 let mock_server = MockServer::start().await; 26 let client_id = mock_server.uri(); 27 let metadata = json!({ 28 "client_id": client_id, 29 "client_name": "Test OAuth Scope Client", 30 "redirect_uris": [redirect_uri], 31 "grant_types": ["authorization_code", "refresh_token"], 32 "response_types": ["code"], 33 "token_endpoint_auth_method": "none", 34 "dpop_bound_access_tokens": false 35 }); 36 Mock::given(method("GET")) 37 .and(path("/")) 38 .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 39 .mount(&mock_server) 40 .await; 41 mock_server 42} 43 44struct OAuthSession { 45 access_token: String, 46 #[allow(dead_code)] 47 refresh_token: String, 48 did: String, 49 #[allow(dead_code)] 50 client_id: String, 51 scope: String, 52} 53 54async fn create_user_and_oauth_session_with_scope( 55 handle_prefix: &str, 56 redirect_uri: &str, 57 scope: &str, 58) -> (OAuthSession, MockServer) { 59 let url = base_url().await; 60 let http_client = client(); 61 let ts = Utc::now().timestamp_millis(); 62 let handle = format!("{}-{}", handle_prefix, ts); 63 let email = format!("{}-{}@example.com", handle_prefix, ts); 64 let password = format!("{}Pass123!", handle_prefix); 65 66 let create_res = http_client 67 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 68 .json(&json!({ 69 "handle": handle, 70 "email": email, 71 "password": password 72 })) 73 .send() 74 .await 75 .expect("Account creation failed"); 76 assert_eq!(create_res.status(), StatusCode::OK); 77 let account: Value = create_res.json().await.unwrap(); 78 let user_did = account["did"].as_str().unwrap().to_string(); 79 80 let _ = verify_new_account(&http_client, &user_did).await; 81 82 let mock_client = setup_mock_client_metadata(redirect_uri).await; 83 let client_id = mock_client.uri(); 84 let (code_verifier, code_challenge) = generate_pkce(); 85 86 let par_res = http_client 87 .post(format!("{}/oauth/par", url)) 88 .form(&[ 89 ("response_type", "code"), 90 ("client_id", &client_id), 91 ("redirect_uri", redirect_uri), 92 ("code_challenge", &code_challenge), 93 ("code_challenge_method", "S256"), 94 ("scope", scope), 95 ]) 96 .send() 97 .await 98 .expect("PAR failed"); 99 assert!( 100 par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED, 101 "PAR should succeed, got {}", 102 par_res.status() 103 ); 104 let par_body: Value = par_res.json().await.unwrap(); 105 let request_uri = par_body["request_uri"].as_str().unwrap(); 106 107 let auth_res = http_client 108 .post(format!("{}/oauth/authorize", url)) 109 .header("Content-Type", "application/json") 110 .header("Accept", "application/json") 111 .json(&json!({ 112 "request_uri": request_uri, 113 "username": &handle, 114 "password": &password, 115 "remember_device": false 116 })) 117 .send() 118 .await 119 .expect("Authorize failed"); 120 assert_eq!( 121 auth_res.status(), 122 StatusCode::OK, 123 "Authorize should return OK" 124 ); 125 let auth_body: Value = auth_res.json().await.unwrap(); 126 let mut location = auth_body["redirect_uri"] 127 .as_str() 128 .expect("Expected redirect_uri") 129 .to_string(); 130 if location.contains("/oauth/consent") { 131 let consent_res = http_client 132 .post(format!("{}/oauth/authorize/consent", url)) 133 .header("Content-Type", "application/json") 134 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 135 .send().await.expect("Consent request failed"); 136 assert_eq!( 137 consent_res.status(), 138 StatusCode::OK, 139 "Consent should succeed" 140 ); 141 let consent_body: Value = consent_res.json().await.unwrap(); 142 location = consent_body["redirect_uri"] 143 .as_str() 144 .expect("Expected redirect_uri from consent") 145 .to_string(); 146 } 147 let code = location 148 .split("code=") 149 .nth(1) 150 .unwrap() 151 .split('&') 152 .next() 153 .unwrap(); 154 155 let token_res = http_client 156 .post(format!("{}/oauth/token", url)) 157 .form(&[ 158 ("grant_type", "authorization_code"), 159 ("code", code), 160 ("redirect_uri", redirect_uri), 161 ("code_verifier", &code_verifier), 162 ("client_id", &client_id), 163 ]) 164 .send() 165 .await 166 .expect("Token request failed"); 167 assert_eq!(token_res.status(), StatusCode::OK); 168 let token_body: Value = token_res.json().await.unwrap(); 169 170 let session = OAuthSession { 171 access_token: token_body["access_token"].as_str().unwrap().to_string(), 172 refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(), 173 did: user_did, 174 client_id, 175 scope: scope.to_string(), 176 }; 177 (session, mock_client) 178} 179 180#[tokio::test] 181async fn test_atproto_scope_allows_full_access() { 182 let url = base_url().await; 183 let http_client = client(); 184 let (session, _mock) = create_user_and_oauth_session_with_scope( 185 "scope-full", 186 "https://example.com/callback", 187 "atproto", 188 ) 189 .await; 190 191 let collection = "app.bsky.feed.post"; 192 let create_res = http_client 193 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 194 .bearer_auth(&session.access_token) 195 .json(&json!({ 196 "repo": session.did, 197 "collection": collection, 198 "record": { 199 "$type": collection, 200 "text": "Full access post", 201 "createdAt": Utc::now().to_rfc3339() 202 } 203 })) 204 .send() 205 .await 206 .unwrap(); 207 208 assert_eq!( 209 create_res.status(), 210 StatusCode::OK, 211 "atproto scope should allow creating records" 212 ); 213 let create_body: Value = create_res.json().await.unwrap(); 214 let rkey = create_body["uri"] 215 .as_str() 216 .unwrap() 217 .split('/') 218 .last() 219 .unwrap(); 220 221 let put_res = http_client 222 .post(format!("{}/xrpc/com.atproto.repo.putRecord", url)) 223 .bearer_auth(&session.access_token) 224 .json(&json!({ 225 "repo": session.did, 226 "collection": collection, 227 "rkey": rkey, 228 "record": { 229 "$type": collection, 230 "text": "Updated post", 231 "createdAt": Utc::now().to_rfc3339() 232 } 233 })) 234 .send() 235 .await 236 .unwrap(); 237 assert_eq!( 238 put_res.status(), 239 StatusCode::OK, 240 "atproto scope should allow updating records" 241 ); 242 243 let delete_res = http_client 244 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 245 .bearer_auth(&session.access_token) 246 .json(&json!({ 247 "repo": session.did, 248 "collection": collection, 249 "rkey": rkey 250 })) 251 .send() 252 .await 253 .unwrap(); 254 assert_eq!( 255 delete_res.status(), 256 StatusCode::OK, 257 "atproto scope should allow deleting records" 258 ); 259} 260 261#[tokio::test] 262async fn test_atproto_scope_allows_blob_upload() { 263 let url = base_url().await; 264 let http_client = client(); 265 let (session, _mock) = create_user_and_oauth_session_with_scope( 266 "scope-blob", 267 "https://example.com/callback", 268 "atproto", 269 ) 270 .await; 271 272 let blob_data = b"Test blob data for scope test"; 273 let upload_res = http_client 274 .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url)) 275 .bearer_auth(&session.access_token) 276 .header("Content-Type", "text/plain") 277 .body(blob_data.to_vec()) 278 .send() 279 .await 280 .unwrap(); 281 282 assert_eq!( 283 upload_res.status(), 284 StatusCode::OK, 285 "atproto scope should allow blob upload" 286 ); 287 let upload_body: Value = upload_res.json().await.unwrap(); 288 assert!(upload_body["blob"]["ref"]["$link"].is_string()); 289} 290 291#[tokio::test] 292async fn test_atproto_scope_allows_batch_writes() { 293 let url = base_url().await; 294 let http_client = client(); 295 let (session, _mock) = create_user_and_oauth_session_with_scope( 296 "scope-batch", 297 "https://example.com/callback", 298 "atproto", 299 ) 300 .await; 301 302 let collection = "app.bsky.feed.post"; 303 let now = Utc::now().to_rfc3339(); 304 let apply_res = http_client 305 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url)) 306 .bearer_auth(&session.access_token) 307 .json(&json!({ 308 "repo": session.did, 309 "writes": [ 310 { 311 "$type": "com.atproto.repo.applyWrites#create", 312 "collection": collection, 313 "rkey": "batch-scope-1", 314 "value": { 315 "$type": collection, 316 "text": "Batch post 1", 317 "createdAt": now 318 } 319 }, 320 { 321 "$type": "com.atproto.repo.applyWrites#create", 322 "collection": collection, 323 "rkey": "batch-scope-2", 324 "value": { 325 "$type": collection, 326 "text": "Batch post 2", 327 "createdAt": now 328 } 329 } 330 ] 331 })) 332 .send() 333 .await 334 .unwrap(); 335 336 assert_eq!( 337 apply_res.status(), 338 StatusCode::OK, 339 "atproto scope should allow batch writes" 340 ); 341} 342 343#[tokio::test] 344async fn test_transition_generic_scope_allows_access() { 345 let url = base_url().await; 346 let http_client = client(); 347 let (session, _mock) = create_user_and_oauth_session_with_scope( 348 "scope-transition", 349 "https://example.com/callback", 350 "atproto transition:generic", 351 ) 352 .await; 353 354 let collection = "app.bsky.feed.post"; 355 let create_res = http_client 356 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 357 .bearer_auth(&session.access_token) 358 .json(&json!({ 359 "repo": session.did, 360 "collection": collection, 361 "record": { 362 "$type": collection, 363 "text": "Post with transition scope", 364 "createdAt": Utc::now().to_rfc3339() 365 } 366 })) 367 .send() 368 .await 369 .unwrap(); 370 371 assert_eq!( 372 create_res.status(), 373 StatusCode::OK, 374 "transition:generic scope combined with atproto should work" 375 ); 376} 377 378#[tokio::test] 379async fn test_consent_endpoint_returns_scope_info() { 380 let url = base_url().await; 381 let http_client = client(); 382 383 let ts = Utc::now().timestamp_millis(); 384 let handle = format!("consent-test-{}", ts); 385 let email = format!("consent-{}@example.com", ts); 386 let password = "Consent123!"; 387 let redirect_uri = "https://consent-test.example.com/callback"; 388 389 let create_res = http_client 390 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 391 .json(&json!({ 392 "handle": handle, 393 "email": email, 394 "password": password 395 })) 396 .send() 397 .await 398 .unwrap(); 399 assert_eq!(create_res.status(), StatusCode::OK); 400 let account: Value = create_res.json().await.unwrap(); 401 let user_did = account["did"].as_str().unwrap(); 402 let _ = verify_new_account(&http_client, user_did).await; 403 404 let mock_client = setup_mock_client_metadata(redirect_uri).await; 405 let client_id = mock_client.uri(); 406 let (_, code_challenge) = generate_pkce(); 407 408 let par_res = http_client 409 .post(format!("{}/oauth/par", url)) 410 .form(&[ 411 ("response_type", "code"), 412 ("client_id", &client_id), 413 ("redirect_uri", redirect_uri), 414 ("code_challenge", &code_challenge), 415 ("code_challenge_method", "S256"), 416 ("scope", "atproto transition:generic"), 417 ]) 418 .send() 419 .await 420 .unwrap(); 421 let par_body: Value = par_res.json().await.unwrap(); 422 let request_uri = par_body["request_uri"].as_str().unwrap(); 423 424 let auth_res = http_client 425 .post(format!("{}/oauth/authorize", url)) 426 .header("Accept", "application/json") 427 .json(&json!({ 428 "request_uri": request_uri, 429 "username": &handle, 430 "password": password, 431 "remember_device": false 432 })) 433 .send() 434 .await 435 .unwrap(); 436 assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed"); 437 438 let consent_res = http_client 439 .get(format!("{}/oauth/authorize/consent", url)) 440 .query(&[("request_uri", request_uri)]) 441 .send() 442 .await 443 .unwrap(); 444 445 assert_eq!(consent_res.status(), StatusCode::OK); 446 let consent_body: Value = consent_res.json().await.unwrap(); 447 448 assert_eq!(consent_body["client_id"], client_id); 449 assert_eq!(consent_body["did"], user_did); 450 assert!(consent_body["scopes"].is_array()); 451 452 let scopes = consent_body["scopes"].as_array().unwrap(); 453 assert!(!scopes.is_empty(), "Should have scopes in response"); 454 455 let atproto_scope = scopes.iter().find(|s| s["scope"] == "atproto"); 456 assert!(atproto_scope.is_some(), "Should include atproto scope"); 457 let atproto = atproto_scope.unwrap(); 458 assert_eq!(atproto["required"], true, "atproto should be required"); 459 assert!(atproto["description"].is_string()); 460 assert!(atproto["display_name"].is_string()); 461 462 let transition_scope = scopes.iter().find(|s| s["scope"] == "transition:generic"); 463 assert!( 464 transition_scope.is_some(), 465 "Should include transition:generic scope" 466 ); 467 let transition = transition_scope.unwrap(); 468 assert_eq!( 469 transition["required"], false, 470 "transition:generic should be optional" 471 ); 472} 473 474#[tokio::test] 475async fn test_consent_post_generates_code() { 476 let url = base_url().await; 477 let http_client = client(); 478 479 let ts = Utc::now().timestamp_millis(); 480 let handle = format!("consent-post-{}", ts); 481 let email = format!("consent-post-{}@example.com", ts); 482 let password = "ConsentPost123!"; 483 let redirect_uri = "https://consent-post.example.com/callback"; 484 485 let create_res = http_client 486 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 487 .json(&json!({ 488 "handle": handle, 489 "email": email, 490 "password": password 491 })) 492 .send() 493 .await 494 .unwrap(); 495 assert_eq!(create_res.status(), StatusCode::OK); 496 let account: Value = create_res.json().await.unwrap(); 497 let user_did = account["did"].as_str().unwrap(); 498 let _ = verify_new_account(&http_client, user_did).await; 499 500 let mock_client = setup_mock_client_metadata(redirect_uri).await; 501 let client_id = mock_client.uri(); 502 let (code_verifier, code_challenge) = generate_pkce(); 503 504 let par_res = http_client 505 .post(format!("{}/oauth/par", url)) 506 .form(&[ 507 ("response_type", "code"), 508 ("client_id", &client_id), 509 ("redirect_uri", redirect_uri), 510 ("code_challenge", &code_challenge), 511 ("code_challenge_method", "S256"), 512 ("scope", "atproto"), 513 ]) 514 .send() 515 .await 516 .unwrap(); 517 let par_body: Value = par_res.json().await.unwrap(); 518 let request_uri = par_body["request_uri"].as_str().unwrap(); 519 520 let auth_res = http_client 521 .post(format!("{}/oauth/authorize", url)) 522 .header("Accept", "application/json") 523 .json(&json!({ 524 "request_uri": request_uri, 525 "username": &handle, 526 "password": password, 527 "remember_device": false 528 })) 529 .send() 530 .await 531 .unwrap(); 532 assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed"); 533 534 let consent_post_res = http_client 535 .post(format!("{}/oauth/authorize/consent", url)) 536 .json(&json!({ 537 "request_uri": request_uri, 538 "approved_scopes": ["atproto"], 539 "remember": false 540 })) 541 .send() 542 .await 543 .unwrap(); 544 545 assert_eq!(consent_post_res.status(), StatusCode::OK); 546 let consent_body: Value = consent_post_res.json().await.unwrap(); 547 assert!( 548 consent_body["redirect_uri"].is_string(), 549 "Should return redirect URI" 550 ); 551 552 let redirect_uri_response = consent_body["redirect_uri"].as_str().unwrap(); 553 assert!( 554 redirect_uri_response.contains("code="), 555 "Redirect URI should contain authorization code" 556 ); 557 558 let code = redirect_uri_response 559 .split("code=") 560 .nth(1) 561 .unwrap() 562 .split('&') 563 .next() 564 .unwrap(); 565 566 let token_res = http_client 567 .post(format!("{}/oauth/token", url)) 568 .form(&[ 569 ("grant_type", "authorization_code"), 570 ("code", code), 571 ("redirect_uri", redirect_uri), 572 ("code_verifier", &code_verifier), 573 ("client_id", &client_id), 574 ]) 575 .send() 576 .await 577 .unwrap(); 578 579 assert_eq!( 580 token_res.status(), 581 StatusCode::OK, 582 "Token exchange should succeed" 583 ); 584 let token_body: Value = token_res.json().await.unwrap(); 585 assert!(token_body["access_token"].is_string()); 586} 587 588#[tokio::test] 589async fn test_consent_post_requires_atproto_scope() { 590 let url = base_url().await; 591 let http_client = client(); 592 593 let ts = Utc::now().timestamp_millis(); 594 let handle = format!("consent-req-{}", ts); 595 let email = format!("consent-req-{}@example.com", ts); 596 let password = "ConsentReq123!"; 597 let redirect_uri = "https://consent-req.example.com/callback"; 598 599 let create_res = http_client 600 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 601 .json(&json!({ 602 "handle": handle, 603 "email": email, 604 "password": password 605 })) 606 .send() 607 .await 608 .unwrap(); 609 assert_eq!(create_res.status(), StatusCode::OK); 610 let account: Value = create_res.json().await.unwrap(); 611 let user_did = account["did"].as_str().unwrap(); 612 let _ = verify_new_account(&http_client, user_did).await; 613 614 let mock_client = setup_mock_client_metadata(redirect_uri).await; 615 let client_id = mock_client.uri(); 616 let (_, code_challenge) = generate_pkce(); 617 618 let par_res = http_client 619 .post(format!("{}/oauth/par", url)) 620 .form(&[ 621 ("response_type", "code"), 622 ("client_id", &client_id), 623 ("redirect_uri", redirect_uri), 624 ("code_challenge", &code_challenge), 625 ("code_challenge_method", "S256"), 626 ("scope", "atproto transition:generic"), 627 ]) 628 .send() 629 .await 630 .unwrap(); 631 let par_body: Value = par_res.json().await.unwrap(); 632 let request_uri = par_body["request_uri"].as_str().unwrap(); 633 634 let auth_res = http_client 635 .post(format!("{}/oauth/authorize", url)) 636 .header("Accept", "application/json") 637 .json(&json!({ 638 "request_uri": request_uri, 639 "username": &handle, 640 "password": password, 641 "remember_device": false 642 })) 643 .send() 644 .await 645 .unwrap(); 646 assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed"); 647 648 let consent_post_res = http_client 649 .post(format!("{}/oauth/authorize/consent", url)) 650 .json(&json!({ 651 "request_uri": request_uri, 652 "approved_scopes": ["transition:generic"], 653 "remember": false 654 })) 655 .send() 656 .await 657 .unwrap(); 658 659 assert_eq!( 660 consent_post_res.status(), 661 StatusCode::BAD_REQUEST, 662 "Should reject consent without atproto scope" 663 ); 664 let error_body: Value = consent_post_res.json().await.unwrap(); 665 assert!( 666 error_body["error_description"] 667 .as_str() 668 .unwrap() 669 .contains("atproto") 670 ); 671} 672 673#[tokio::test] 674async fn test_token_contains_requested_scope() { 675 let scope = "atproto transition:generic"; 676 let (session, _mock) = create_user_and_oauth_session_with_scope( 677 "scope-token", 678 "https://example.com/callback", 679 scope, 680 ) 681 .await; 682 683 assert_eq!( 684 session.scope, scope, 685 "Session should have the requested scope" 686 ); 687 688 let parts: Vec<&str> = session.access_token.split('.').collect(); 689 assert_eq!(parts.len(), 3, "Token should be a valid JWT"); 690 691 let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 692 let payload: Value = serde_json::from_slice(&payload_json).unwrap(); 693 694 assert!( 695 payload["scope"].is_string(), 696 "Token payload should contain scope" 697 ); 698 let token_scope = payload["scope"].as_str().unwrap(); 699 assert!( 700 token_scope.contains("atproto"), 701 "Token scope should contain atproto" 702 ); 703} 704 705#[tokio::test] 706async fn test_dereference_scope_endpoint() { 707 let url = base_url().await; 708 let http_client = client(); 709 let (session, _mock) = create_user_and_oauth_session_with_scope( 710 "scope-deref", 711 "https://example.com/callback", 712 "atproto", 713 ) 714 .await; 715 716 let deref_res = http_client 717 .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url)) 718 .bearer_auth(&session.access_token) 719 .json(&json!({ 720 "scope": "atproto transition:generic" 721 })) 722 .send() 723 .await 724 .unwrap(); 725 726 assert_eq!(deref_res.status(), StatusCode::OK); 727 let deref_body: Value = deref_res.json().await.unwrap(); 728 assert!(deref_body["scope"].is_string()); 729 let resolved_scope = deref_body["scope"].as_str().unwrap(); 730 assert!(resolved_scope.contains("atproto")); 731 assert!(resolved_scope.contains("transition:generic")); 732} 733 734#[tokio::test] 735async fn test_dereference_scope_requires_auth() { 736 let url = base_url().await; 737 let http_client = client(); 738 739 let deref_res = http_client 740 .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url)) 741 .json(&json!({ 742 "scope": "atproto" 743 })) 744 .send() 745 .await 746 .unwrap(); 747 748 assert_eq!( 749 deref_res.status(), 750 StatusCode::UNAUTHORIZED, 751 "Should require authentication" 752 ); 753}