this repo has no description
1mod common; 2mod helpers; 3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4use common::{base_url, client, get_test_db_pool}; 5use helpers::verify_new_account; 6use reqwest::{StatusCode, redirect}; 7use serde_json::{Value, json}; 8use sha2::{Digest, Sha256}; 9use wiremock::matchers::{method, path}; 10use wiremock::{Mock, MockServer, ResponseTemplate}; 11 12fn no_redirect_client() -> reqwest::Client { 13 reqwest::Client::builder() 14 .redirect(redirect::Policy::none()) 15 .build() 16 .unwrap() 17} 18 19fn generate_pkce() -> (String, String) { 20 let verifier_bytes: [u8; 32] = rand::random(); 21 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 22 let mut hasher = Sha256::new(); 23 hasher.update(code_verifier.as_bytes()); 24 let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); 25 (code_verifier, code_challenge) 26} 27 28async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 29 let mock_server = MockServer::start().await; 30 let client_id = mock_server.uri(); 31 let metadata = json!({ 32 "client_id": client_id, 33 "client_name": "Test OAuth Client", 34 "redirect_uris": [redirect_uri], 35 "grant_types": ["authorization_code", "refresh_token"], 36 "response_types": ["code"], 37 "token_endpoint_auth_method": "none", 38 "dpop_bound_access_tokens": false 39 }); 40 Mock::given(method("GET")) 41 .and(path("/")) 42 .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 43 .mount(&mock_server) 44 .await; 45 mock_server 46} 47 48#[tokio::test] 49async fn test_oauth_metadata_endpoints() { 50 let url = base_url().await; 51 let client = client(); 52 let pr_res = client 53 .get(format!("{}/.well-known/oauth-protected-resource", url)) 54 .send() 55 .await 56 .unwrap(); 57 assert_eq!(pr_res.status(), StatusCode::OK); 58 let pr_body: Value = pr_res.json().await.unwrap(); 59 assert!(pr_body["resource"].is_string()); 60 assert!(pr_body["authorization_servers"].is_array()); 61 assert!( 62 pr_body["bearer_methods_supported"] 63 .as_array() 64 .unwrap() 65 .contains(&json!("header")) 66 ); 67 let as_res = client 68 .get(format!("{}/.well-known/oauth-authorization-server", url)) 69 .send() 70 .await 71 .unwrap(); 72 assert_eq!(as_res.status(), StatusCode::OK); 73 let as_body: Value = as_res.json().await.unwrap(); 74 assert!(as_body["issuer"].is_string()); 75 assert!(as_body["authorization_endpoint"].is_string()); 76 assert!(as_body["token_endpoint"].is_string()); 77 assert!(as_body["jwks_uri"].is_string()); 78 assert!( 79 as_body["response_types_supported"] 80 .as_array() 81 .unwrap() 82 .contains(&json!("code")) 83 ); 84 assert!( 85 as_body["grant_types_supported"] 86 .as_array() 87 .unwrap() 88 .contains(&json!("authorization_code")) 89 ); 90 assert!( 91 as_body["code_challenge_methods_supported"] 92 .as_array() 93 .unwrap() 94 .contains(&json!("S256")) 95 ); 96 assert_eq!( 97 as_body["require_pushed_authorization_requests"], 98 json!(true) 99 ); 100 assert!( 101 as_body["dpop_signing_alg_values_supported"] 102 .as_array() 103 .unwrap() 104 .contains(&json!("ES256")) 105 ); 106 let jwks_res = client 107 .get(format!("{}/oauth/jwks", url)) 108 .send() 109 .await 110 .unwrap(); 111 assert_eq!(jwks_res.status(), StatusCode::OK); 112 let jwks_body: Value = jwks_res.json().await.unwrap(); 113 assert!(jwks_body["keys"].is_array()); 114} 115 116#[tokio::test] 117async fn test_par_and_authorize() { 118 let url = base_url().await; 119 let client = client(); 120 let redirect_uri = "https://example.com/callback"; 121 let mock_client = setup_mock_client_metadata(redirect_uri).await; 122 let client_id = mock_client.uri(); 123 let (_, code_challenge) = generate_pkce(); 124 let par_res = client 125 .post(format!("{}/oauth/par", url)) 126 .form(&[ 127 ("response_type", "code"), 128 ("client_id", &client_id), 129 ("redirect_uri", redirect_uri), 130 ("code_challenge", &code_challenge), 131 ("code_challenge_method", "S256"), 132 ("scope", "atproto"), 133 ("state", "test-state"), 134 ]) 135 .send() 136 .await 137 .unwrap(); 138 assert_eq!(par_res.status(), StatusCode::CREATED, "PAR should succeed"); 139 let par_body: Value = par_res.json().await.unwrap(); 140 assert!(par_body["request_uri"].is_string()); 141 assert!(par_body["expires_in"].is_number()); 142 let request_uri = par_body["request_uri"].as_str().unwrap(); 143 assert!(request_uri.starts_with("urn:ietf:params:oauth:request_uri:")); 144 let auth_res = client 145 .get(format!("{}/oauth/authorize", url)) 146 .header("Accept", "application/json") 147 .query(&[("request_uri", request_uri)]) 148 .send() 149 .await 150 .unwrap(); 151 assert_eq!(auth_res.status(), StatusCode::OK); 152 let auth_body: Value = auth_res.json().await.unwrap(); 153 assert_eq!(auth_body["client_id"], client_id); 154 assert_eq!(auth_body["redirect_uri"], redirect_uri); 155 assert_eq!(auth_body["scope"], "atproto"); 156 let invalid_res = client 157 .get(format!("{}/oauth/authorize", url)) 158 .header("Accept", "application/json") 159 .query(&[( 160 "request_uri", 161 "urn:ietf:params:oauth:request_uri:nonexistent", 162 )]) 163 .send() 164 .await 165 .unwrap(); 166 assert_eq!(invalid_res.status(), StatusCode::BAD_REQUEST); 167 let missing_client = no_redirect_client(); 168 let missing_res = missing_client 169 .get(format!("{}/oauth/authorize", url)) 170 .send() 171 .await 172 .unwrap(); 173 assert!( 174 missing_res.status().is_redirection(), 175 "Should redirect to error page" 176 ); 177 let error_location = missing_res 178 .headers() 179 .get("location") 180 .unwrap() 181 .to_str() 182 .unwrap(); 183 assert!( 184 error_location.contains("oauth/error"), 185 "Should redirect to error page" 186 ); 187} 188 189#[tokio::test] 190async fn test_full_oauth_flow() { 191 let url = base_url().await; 192 let http_client = client(); 193 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 194 let handle = format!("ot{}", suffix); 195 let email = format!("ot{}@example.com", suffix); 196 let password = "Oauthtest123!"; 197 let create_res = http_client 198 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 199 .json(&json!({ "handle": handle, "email": email, "password": password })) 200 .send() 201 .await 202 .unwrap(); 203 assert_eq!(create_res.status(), StatusCode::OK); 204 let account: Value = create_res.json().await.unwrap(); 205 let user_did = account["did"].as_str().unwrap(); 206 verify_new_account(&http_client, user_did).await; 207 let redirect_uri = "https://example.com/oauth/callback"; 208 let mock_client = setup_mock_client_metadata(redirect_uri).await; 209 let client_id = mock_client.uri(); 210 let (code_verifier, code_challenge) = generate_pkce(); 211 let state = format!("state-{}", suffix); 212 let par_res = http_client 213 .post(format!("{}/oauth/par", url)) 214 .form(&[ 215 ("response_type", "code"), 216 ("client_id", &client_id), 217 ("redirect_uri", redirect_uri), 218 ("code_challenge", &code_challenge), 219 ("code_challenge_method", "S256"), 220 ("scope", "atproto"), 221 ("state", &state), 222 ]) 223 .send() 224 .await 225 .unwrap(); 226 let par_body: Value = par_res.json().await.unwrap(); 227 let request_uri = par_body["request_uri"].as_str().unwrap(); 228 let auth_res = http_client 229 .post(format!("{}/oauth/authorize", url)) 230 .header("Content-Type", "application/json") 231 .header("Accept", "application/json") 232 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 233 .send().await.unwrap(); 234 assert_eq!( 235 auth_res.status(), 236 StatusCode::OK, 237 "Expected OK with JSON response" 238 ); 239 let auth_body: Value = auth_res.json().await.unwrap(); 240 let mut location = auth_body["redirect_uri"] 241 .as_str() 242 .expect("Expected redirect_uri in response") 243 .to_string(); 244 if location.contains("/oauth/consent") { 245 let consent_res = http_client 246 .post(format!("{}/oauth/authorize/consent", url)) 247 .header("Content-Type", "application/json") 248 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 249 .send().await.unwrap(); 250 let consent_status = consent_res.status(); 251 let consent_body: Value = consent_res.json().await.unwrap(); 252 assert_eq!( 253 consent_status, 254 StatusCode::OK, 255 "Consent should succeed. Got: {:?}", 256 consent_body 257 ); 258 location = consent_body["redirect_uri"] 259 .as_str() 260 .expect("Expected redirect_uri from consent") 261 .to_string(); 262 } 263 assert!( 264 location.starts_with(redirect_uri), 265 "Redirect to wrong URI: {}", 266 location 267 ); 268 assert!(location.contains("code="), "No code in redirect"); 269 assert!( 270 location.contains(&format!("state={}", state)), 271 "Wrong state" 272 ); 273 let code = location 274 .split("code=") 275 .nth(1) 276 .unwrap() 277 .split('&') 278 .next() 279 .unwrap(); 280 let token_res = http_client 281 .post(format!("{}/oauth/token", url)) 282 .form(&[ 283 ("grant_type", "authorization_code"), 284 ("code", code), 285 ("redirect_uri", redirect_uri), 286 ("code_verifier", &code_verifier), 287 ("client_id", &client_id), 288 ]) 289 .send() 290 .await 291 .unwrap(); 292 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); 293 let token_body: Value = token_res.json().await.unwrap(); 294 assert!(token_body["access_token"].is_string()); 295 assert!(token_body["refresh_token"].is_string()); 296 assert_eq!(token_body["token_type"], "Bearer"); 297 assert!(token_body["expires_in"].is_number()); 298 assert_eq!(token_body["sub"], user_did); 299 let access_token = token_body["access_token"].as_str().unwrap(); 300 let refresh_token = token_body["refresh_token"].as_str().unwrap(); 301 let refresh_res = http_client 302 .post(format!("{}/oauth/token", url)) 303 .form(&[ 304 ("grant_type", "refresh_token"), 305 ("refresh_token", refresh_token), 306 ("client_id", &client_id), 307 ]) 308 .send() 309 .await 310 .unwrap(); 311 assert_eq!(refresh_res.status(), StatusCode::OK); 312 let refresh_body: Value = refresh_res.json().await.unwrap(); 313 assert_ne!(refresh_body["access_token"].as_str().unwrap(), access_token); 314 assert_ne!( 315 refresh_body["refresh_token"].as_str().unwrap(), 316 refresh_token 317 ); 318 let introspect_res = http_client 319 .post(format!("{}/oauth/introspect", url)) 320 .form(&[("token", refresh_body["access_token"].as_str().unwrap())]) 321 .send() 322 .await 323 .unwrap(); 324 assert_eq!(introspect_res.status(), StatusCode::OK); 325 let introspect_body: Value = introspect_res.json().await.unwrap(); 326 assert_eq!(introspect_body["active"], true); 327 let revoke_res = http_client 328 .post(format!("{}/oauth/revoke", url)) 329 .form(&[("token", refresh_body["refresh_token"].as_str().unwrap())]) 330 .send() 331 .await 332 .unwrap(); 333 assert_eq!(revoke_res.status(), StatusCode::OK); 334 let introspect_after = http_client 335 .post(format!("{}/oauth/introspect", url)) 336 .form(&[("token", refresh_body["access_token"].as_str().unwrap())]) 337 .send() 338 .await 339 .unwrap(); 340 let after_body: Value = introspect_after.json().await.unwrap(); 341 assert_eq!( 342 after_body["active"], false, 343 "Revoked token should be inactive" 344 ); 345} 346 347#[tokio::test] 348async fn test_oauth_error_cases() { 349 let url = base_url().await; 350 let http_client = client(); 351 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 352 let handle = format!("wc{}", suffix); 353 let email = format!("wc{}@example.com", suffix); 354 http_client 355 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 356 .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" })) 357 .send() 358 .await 359 .unwrap(); 360 let redirect_uri = "https://example.com/callback"; 361 let mock_client = setup_mock_client_metadata(redirect_uri).await; 362 let client_id = mock_client.uri(); 363 let (_, code_challenge) = generate_pkce(); 364 let par_body: Value = http_client 365 .post(format!("{}/oauth/par", url)) 366 .form(&[ 367 ("response_type", "code"), 368 ("client_id", &client_id), 369 ("redirect_uri", redirect_uri), 370 ("code_challenge", &code_challenge), 371 ("code_challenge_method", "S256"), 372 ]) 373 .send() 374 .await 375 .unwrap() 376 .json() 377 .await 378 .unwrap(); 379 let request_uri = par_body["request_uri"].as_str().unwrap(); 380 let auth_res = http_client 381 .post(format!("{}/oauth/authorize", url)) 382 .header("Content-Type", "application/json") 383 .header("Accept", "application/json") 384 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "wrong-password", "remember_device": false})) 385 .send().await.unwrap(); 386 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN); 387 let error_body: Value = auth_res.json().await.unwrap(); 388 assert_eq!(error_body["error"], "access_denied"); 389 let unsupported = http_client 390 .post(format!("{}/oauth/token", url)) 391 .form(&[ 392 ("grant_type", "client_credentials"), 393 ("client_id", "https://example.com"), 394 ]) 395 .send() 396 .await 397 .unwrap(); 398 assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST); 399 let body: Value = unsupported.json().await.unwrap(); 400 assert_eq!(body["error"], "unsupported_grant_type"); 401 let invalid_refresh = http_client 402 .post(format!("{}/oauth/token", url)) 403 .form(&[ 404 ("grant_type", "refresh_token"), 405 ("refresh_token", "invalid-token"), 406 ("client_id", "https://example.com"), 407 ]) 408 .send() 409 .await 410 .unwrap(); 411 assert_eq!(invalid_refresh.status(), StatusCode::BAD_REQUEST); 412 let body: Value = invalid_refresh.json().await.unwrap(); 413 assert_eq!(body["error"], "invalid_grant"); 414 let invalid_introspect = http_client 415 .post(format!("{}/oauth/introspect", url)) 416 .form(&[("token", "invalid.token.here")]) 417 .send() 418 .await 419 .unwrap(); 420 assert_eq!(invalid_introspect.status(), StatusCode::OK); 421 let body: Value = invalid_introspect.json().await.unwrap(); 422 assert_eq!(body["active"], false); 423 let expired_res = http_client 424 .get(format!("{}/oauth/authorize", url)) 425 .header("Accept", "application/json") 426 .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired")]) 427 .send() 428 .await 429 .unwrap(); 430 assert_eq!(expired_res.status(), StatusCode::BAD_REQUEST); 431} 432 433#[tokio::test] 434async fn test_oauth_2fa_flow() { 435 let url = base_url().await; 436 let http_client = client(); 437 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 438 let handle = format!("ft{}", suffix); 439 let email = format!("ft{}@example.com", suffix); 440 let password = "Twofa123test!"; 441 let create_res = http_client 442 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 443 .json(&json!({ "handle": handle, "email": email, "password": password })) 444 .send() 445 .await 446 .unwrap(); 447 assert_eq!(create_res.status(), StatusCode::OK); 448 let account: Value = create_res.json().await.unwrap(); 449 let user_did = account["did"].as_str().unwrap(); 450 verify_new_account(&http_client, user_did).await; 451 let pool = get_test_db_pool().await; 452 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") 453 .bind(user_did) 454 .execute(pool) 455 .await 456 .unwrap(); 457 let redirect_uri = "https://example.com/2fa-callback"; 458 let mock_client = setup_mock_client_metadata(redirect_uri).await; 459 let client_id = mock_client.uri(); 460 let (code_verifier, code_challenge) = generate_pkce(); 461 let par_body: Value = http_client 462 .post(format!("{}/oauth/par", url)) 463 .form(&[ 464 ("response_type", "code"), 465 ("client_id", &client_id), 466 ("redirect_uri", redirect_uri), 467 ("code_challenge", &code_challenge), 468 ("code_challenge_method", "S256"), 469 ]) 470 .send() 471 .await 472 .unwrap() 473 .json() 474 .await 475 .unwrap(); 476 let request_uri = par_body["request_uri"].as_str().unwrap(); 477 let auth_res = http_client 478 .post(format!("{}/oauth/authorize", url)) 479 .header("Content-Type", "application/json") 480 .header("Accept", "application/json") 481 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 482 .send().await.unwrap(); 483 assert_eq!( 484 auth_res.status(), 485 StatusCode::OK, 486 "Should return OK with needs_2fa" 487 ); 488 let auth_body: Value = auth_res.json().await.unwrap(); 489 assert!( 490 auth_body["needs_2fa"].as_bool().unwrap_or(false), 491 "Should need 2FA, got: {:?}", 492 auth_body 493 ); 494 let twofa_invalid = http_client 495 .post(format!("{}/oauth/authorize/2fa", url)) 496 .header("Content-Type", "application/json") 497 .json(&json!({"request_uri": request_uri, "code": "000000"})) 498 .send() 499 .await 500 .unwrap(); 501 assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN); 502 let body: Value = twofa_invalid.json().await.unwrap(); 503 assert!( 504 body["error_description"] 505 .as_str() 506 .unwrap_or("") 507 .contains("Invalid") 508 || body["error"].as_str().unwrap_or("") == "invalid_code" 509 ); 510 let twofa_code: String = 511 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") 512 .bind(request_uri) 513 .fetch_one(pool) 514 .await 515 .unwrap(); 516 let twofa_res = http_client 517 .post(format!("{}/oauth/authorize/2fa", url)) 518 .header("Content-Type", "application/json") 519 .json(&json!({"request_uri": request_uri, "code": &twofa_code})) 520 .send() 521 .await 522 .unwrap(); 523 assert_eq!( 524 twofa_res.status(), 525 StatusCode::OK, 526 "Valid 2FA code should succeed" 527 ); 528 let twofa_body: Value = twofa_res.json().await.unwrap(); 529 let final_location = twofa_body["redirect_uri"].as_str().unwrap(); 530 assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); 531 let auth_code = final_location 532 .split("code=") 533 .nth(1) 534 .unwrap() 535 .split('&') 536 .next() 537 .unwrap(); 538 let token_res = http_client 539 .post(format!("{}/oauth/token", url)) 540 .form(&[ 541 ("grant_type", "authorization_code"), 542 ("code", auth_code), 543 ("redirect_uri", redirect_uri), 544 ("code_verifier", &code_verifier), 545 ("client_id", &client_id), 546 ]) 547 .send() 548 .await 549 .unwrap(); 550 assert_eq!(token_res.status(), StatusCode::OK); 551 let token_body: Value = token_res.json().await.unwrap(); 552 assert_eq!(token_body["sub"], user_did); 553} 554 555#[tokio::test] 556async fn test_oauth_2fa_lockout() { 557 let url = base_url().await; 558 let http_client = client(); 559 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 560 let handle = format!("fl{}", suffix); 561 let email = format!("fl{}@example.com", suffix); 562 let password = "Twofa123test!"; 563 let create_res = http_client 564 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 565 .json(&json!({ "handle": handle, "email": email, "password": password })) 566 .send() 567 .await 568 .unwrap(); 569 let account: Value = create_res.json().await.unwrap(); 570 let user_did = account["did"].as_str().unwrap(); 571 verify_new_account(&http_client, user_did).await; 572 let pool = get_test_db_pool().await; 573 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") 574 .bind(user_did) 575 .execute(pool) 576 .await 577 .unwrap(); 578 let redirect_uri = "https://example.com/2fa-lockout-callback"; 579 let mock_client = setup_mock_client_metadata(redirect_uri).await; 580 let client_id = mock_client.uri(); 581 let (_, code_challenge) = generate_pkce(); 582 let par_body: Value = http_client 583 .post(format!("{}/oauth/par", url)) 584 .form(&[ 585 ("response_type", "code"), 586 ("client_id", &client_id), 587 ("redirect_uri", redirect_uri), 588 ("code_challenge", &code_challenge), 589 ("code_challenge_method", "S256"), 590 ]) 591 .send() 592 .await 593 .unwrap() 594 .json() 595 .await 596 .unwrap(); 597 let request_uri = par_body["request_uri"].as_str().unwrap(); 598 let auth_res = http_client 599 .post(format!("{}/oauth/authorize", url)) 600 .header("Content-Type", "application/json") 601 .header("Accept", "application/json") 602 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 603 .send().await.unwrap(); 604 assert_eq!( 605 auth_res.status(), 606 StatusCode::OK, 607 "Should return OK with needs_2fa" 608 ); 609 let auth_body: Value = auth_res.json().await.unwrap(); 610 assert!( 611 auth_body["needs_2fa"].as_bool().unwrap_or(false), 612 "Should need 2FA" 613 ); 614 for i in 0..5 { 615 let res = http_client 616 .post(format!("{}/oauth/authorize/2fa", url)) 617 .header("Content-Type", "application/json") 618 .json(&json!({"request_uri": request_uri, "code": "999999"})) 619 .send() 620 .await 621 .unwrap(); 622 if i < 4 { 623 assert_eq!( 624 res.status(), 625 StatusCode::FORBIDDEN, 626 "Attempt {} should return 403", 627 i 628 ); 629 } 630 } 631 let lockout_res = http_client 632 .post(format!("{}/oauth/authorize/2fa", url)) 633 .header("Content-Type", "application/json") 634 .json(&json!({"request_uri": request_uri, "code": "999999"})) 635 .send() 636 .await 637 .unwrap(); 638 let body: Value = lockout_res.json().await.unwrap(); 639 let desc = body["error_description"].as_str().unwrap_or(""); 640 assert!( 641 desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request", 642 "Expected lockout error, got: {:?}", 643 body 644 ); 645} 646 647#[tokio::test] 648async fn test_account_selector_with_2fa() { 649 let url = base_url().await; 650 let http_client = client(); 651 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 652 let handle = format!("sf{}", suffix); 653 let email = format!("sf{}@example.com", suffix); 654 let password = "Selector2fa123!"; 655 let create_res = http_client 656 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 657 .json(&json!({ "handle": handle, "email": email, "password": password })) 658 .send() 659 .await 660 .unwrap(); 661 let account: Value = create_res.json().await.unwrap(); 662 let user_did = account["did"].as_str().unwrap().to_string(); 663 verify_new_account(&http_client, &user_did).await; 664 let redirect_uri = "https://example.com/selector-2fa-callback"; 665 let mock_client = setup_mock_client_metadata(redirect_uri).await; 666 let client_id = mock_client.uri(); 667 let (code_verifier, code_challenge) = generate_pkce(); 668 let par_body: Value = http_client 669 .post(format!("{}/oauth/par", url)) 670 .form(&[ 671 ("response_type", "code"), 672 ("client_id", &client_id), 673 ("redirect_uri", redirect_uri), 674 ("code_challenge", &code_challenge), 675 ("code_challenge_method", "S256"), 676 ]) 677 .send() 678 .await 679 .unwrap() 680 .json() 681 .await 682 .unwrap(); 683 let request_uri = par_body["request_uri"].as_str().unwrap(); 684 let auth_res = http_client 685 .post(format!("{}/oauth/authorize", url)) 686 .header("Content-Type", "application/json") 687 .header("Accept", "application/json") 688 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true})) 689 .send().await.unwrap(); 690 assert_eq!( 691 auth_res.status(), 692 StatusCode::OK, 693 "Expected OK with JSON response" 694 ); 695 let device_cookie = auth_res 696 .headers() 697 .get("set-cookie") 698 .and_then(|v| v.to_str().ok()) 699 .map(|s| s.split(';').next().unwrap_or("").to_string()) 700 .expect("Should have device cookie"); 701 let auth_body: Value = auth_res.json().await.unwrap(); 702 let mut location = auth_body["redirect_uri"] 703 .as_str() 704 .expect("Expected redirect_uri") 705 .to_string(); 706 if location.contains("/oauth/consent") { 707 let consent_res = http_client 708 .post(format!("{}/oauth/authorize/consent", url)) 709 .header("Content-Type", "application/json") 710 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": true})) 711 .send().await.unwrap(); 712 assert_eq!( 713 consent_res.status(), 714 StatusCode::OK, 715 "Consent should succeed" 716 ); 717 let consent_body: Value = consent_res.json().await.unwrap(); 718 location = consent_body["redirect_uri"] 719 .as_str() 720 .expect("Expected redirect_uri from consent") 721 .to_string(); 722 } 723 assert!(location.contains("code=")); 724 let code = location 725 .split("code=") 726 .nth(1) 727 .unwrap() 728 .split('&') 729 .next() 730 .unwrap(); 731 let _ = http_client 732 .post(format!("{}/oauth/token", url)) 733 .form(&[ 734 ("grant_type", "authorization_code"), 735 ("code", code), 736 ("redirect_uri", redirect_uri), 737 ("code_verifier", &code_verifier), 738 ("client_id", &client_id), 739 ]) 740 .send() 741 .await 742 .unwrap() 743 .json::<Value>() 744 .await 745 .unwrap(); 746 let pool = get_test_db_pool().await; 747 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") 748 .bind(&user_did) 749 .execute(pool) 750 .await 751 .unwrap(); 752 let (code_verifier2, code_challenge2) = generate_pkce(); 753 let par_body2: Value = http_client 754 .post(format!("{}/oauth/par", url)) 755 .form(&[ 756 ("response_type", "code"), 757 ("client_id", &client_id), 758 ("redirect_uri", redirect_uri), 759 ("code_challenge", &code_challenge2), 760 ("code_challenge_method", "S256"), 761 ]) 762 .send() 763 .await 764 .unwrap() 765 .json() 766 .await 767 .unwrap(); 768 let request_uri2 = par_body2["request_uri"].as_str().unwrap(); 769 let select_res = http_client 770 .post(format!("{}/oauth/authorize/select", url)) 771 .header("cookie", &device_cookie) 772 .header("Content-Type", "application/json") 773 .json(&json!({"request_uri": request_uri2, "did": &user_did})) 774 .send() 775 .await 776 .unwrap(); 777 assert_eq!( 778 select_res.status(), 779 StatusCode::OK, 780 "Select should return OK with JSON" 781 ); 782 let select_body: Value = select_res.json().await.unwrap(); 783 assert!( 784 select_body["needs_2fa"].as_bool().unwrap_or(false), 785 "Should need 2FA" 786 ); 787 let twofa_code: String = 788 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") 789 .bind(request_uri2) 790 .fetch_one(pool) 791 .await 792 .unwrap(); 793 let twofa_res = http_client 794 .post(format!("{}/oauth/authorize/2fa", url)) 795 .header("cookie", &device_cookie) 796 .header("Content-Type", "application/json") 797 .json(&json!({"request_uri": request_uri2, "code": &twofa_code})) 798 .send() 799 .await 800 .unwrap(); 801 assert_eq!( 802 twofa_res.status(), 803 StatusCode::OK, 804 "Valid 2FA should succeed" 805 ); 806 let twofa_body: Value = twofa_res.json().await.unwrap(); 807 let final_location = twofa_body["redirect_uri"].as_str().unwrap(); 808 assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); 809 let final_code = final_location 810 .split("code=") 811 .nth(1) 812 .unwrap() 813 .split('&') 814 .next() 815 .unwrap(); 816 let token_res = http_client 817 .post(format!("{}/oauth/token", url)) 818 .form(&[ 819 ("grant_type", "authorization_code"), 820 ("code", final_code), 821 ("redirect_uri", redirect_uri), 822 ("code_verifier", &code_verifier2), 823 ("client_id", &client_id), 824 ]) 825 .send() 826 .await 827 .unwrap(); 828 assert_eq!(token_res.status(), StatusCode::OK); 829 let final_token: Value = token_res.json().await.unwrap(); 830 assert_eq!(final_token["sub"], user_did); 831} 832 833#[tokio::test] 834async fn test_oauth_state_encoding() { 835 let url = base_url().await; 836 let http_client = client(); 837 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 838 let handle = format!("ss{}", suffix); 839 let email = format!("ss{}@example.com", suffix); 840 let password = "State123special!"; 841 let create_res = http_client 842 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 843 .json(&json!({ "handle": handle, "email": email, "password": password })) 844 .send() 845 .await 846 .unwrap(); 847 let account: Value = create_res.json().await.unwrap(); 848 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 849 let redirect_uri = "https://example.com/state-special-callback"; 850 let mock_client = setup_mock_client_metadata(redirect_uri).await; 851 let client_id = mock_client.uri(); 852 let (_, code_challenge) = generate_pkce(); 853 let special_state = "state=with&special=chars&plus+more"; 854 let par_body: Value = http_client 855 .post(format!("{}/oauth/par", url)) 856 .form(&[ 857 ("response_type", "code"), 858 ("client_id", &client_id), 859 ("redirect_uri", redirect_uri), 860 ("code_challenge", &code_challenge), 861 ("code_challenge_method", "S256"), 862 ("state", special_state), 863 ]) 864 .send() 865 .await 866 .unwrap() 867 .json() 868 .await 869 .unwrap(); 870 let request_uri = par_body["request_uri"].as_str().unwrap(); 871 let auth_res = http_client 872 .post(format!("{}/oauth/authorize", url)) 873 .header("Content-Type", "application/json") 874 .header("Accept", "application/json") 875 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 876 .send().await.unwrap(); 877 assert_eq!( 878 auth_res.status(), 879 StatusCode::OK, 880 "Expected OK with JSON response" 881 ); 882 let auth_body: Value = auth_res.json().await.unwrap(); 883 let mut location = auth_body["redirect_uri"] 884 .as_str() 885 .expect("Expected redirect_uri") 886 .to_string(); 887 if location.contains("/oauth/consent") { 888 let consent_res = http_client 889 .post(format!("{}/oauth/authorize/consent", url)) 890 .header("Content-Type", "application/json") 891 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 892 .send().await.unwrap(); 893 assert_eq!( 894 consent_res.status(), 895 StatusCode::OK, 896 "Consent should succeed" 897 ); 898 let consent_body: Value = consent_res.json().await.unwrap(); 899 location = consent_body["redirect_uri"] 900 .as_str() 901 .expect("Expected redirect_uri from consent") 902 .to_string(); 903 } 904 assert!(location.contains("state=")); 905 let encoded_state = urlencoding::encode(special_state); 906 assert!( 907 location.contains(&format!("state={}", encoded_state)), 908 "State should be URL-encoded. Got: {}", 909 location 910 ); 911} 912 913async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) { 914 let url = base_url().await; 915 let http_client = client(); 916 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 917 let handle = format!("st{}", suffix); 918 let email = format!("st{}@example.com", suffix); 919 let password = "Scopetest123!"; 920 let create_res = http_client 921 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 922 .json(&json!({ "handle": handle, "email": email, "password": password })) 923 .send() 924 .await 925 .unwrap(); 926 assert_eq!(create_res.status(), StatusCode::OK); 927 let account: Value = create_res.json().await.unwrap(); 928 let user_did = account["did"].as_str().unwrap().to_string(); 929 verify_new_account(&http_client, &user_did).await; 930 let redirect_uri = "https://example.com/scope-callback"; 931 let mock_client = setup_mock_client_metadata(redirect_uri).await; 932 let client_id = mock_client.uri(); 933 let (code_verifier, code_challenge) = generate_pkce(); 934 let par_res = http_client 935 .post(format!("{}/oauth/par", url)) 936 .form(&[ 937 ("response_type", "code"), 938 ("client_id", &client_id), 939 ("redirect_uri", redirect_uri), 940 ("code_challenge", &code_challenge), 941 ("code_challenge_method", "S256"), 942 ("scope", scope), 943 ("state", "test"), 944 ]) 945 .send() 946 .await 947 .unwrap(); 948 assert_eq!( 949 par_res.status(), 950 StatusCode::CREATED, 951 "PAR should succeed for scope: {}", 952 scope 953 ); 954 let par_body: Value = par_res.json().await.unwrap(); 955 let request_uri = par_body["request_uri"].as_str().unwrap(); 956 let auth_res = http_client 957 .post(format!("{}/oauth/authorize", url)) 958 .header("Content-Type", "application/json") 959 .header("Accept", "application/json") 960 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 961 .send().await.unwrap(); 962 assert_eq!(auth_res.status(), StatusCode::OK); 963 let auth_body: Value = auth_res.json().await.unwrap(); 964 let mut location = auth_body["redirect_uri"] 965 .as_str() 966 .expect("Expected redirect_uri") 967 .to_string(); 968 if location.contains("/oauth/consent") { 969 let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); 970 let consent_res = http_client 971 .post(format!("{}/oauth/authorize/consent", url)) 972 .header("Content-Type", "application/json") 973 .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false})) 974 .send().await.unwrap(); 975 let consent_status = consent_res.status(); 976 let consent_body: Value = consent_res.json().await.unwrap(); 977 assert_eq!( 978 consent_status, 979 StatusCode::OK, 980 "Consent should succeed. Scope: {}, Body: {:?}", 981 scope, 982 consent_body 983 ); 984 location = consent_body["redirect_uri"] 985 .as_str() 986 .expect("Expected redirect_uri from consent") 987 .to_string(); 988 } 989 let code = location 990 .split("code=") 991 .nth(1) 992 .unwrap() 993 .split('&') 994 .next() 995 .unwrap(); 996 let token_res = http_client 997 .post(format!("{}/oauth/token", url)) 998 .form(&[ 999 ("grant_type", "authorization_code"), 1000 ("code", code), 1001 ("redirect_uri", redirect_uri), 1002 ("code_verifier", &code_verifier), 1003 ("client_id", &client_id), 1004 ]) 1005 .send() 1006 .await 1007 .unwrap(); 1008 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); 1009 let token_body: Value = token_res.json().await.unwrap(); 1010 let access_token = token_body["access_token"].as_str().unwrap().to_string(); 1011 (access_token, user_did, handle) 1012} 1013 1014#[tokio::test] 1015async fn test_granular_scope_repo_create_only() { 1016 let url = base_url().await; 1017 let http_client = client(); 1018 let (token, did, _) = 1019 get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await; 1020 let now = chrono::Utc::now().to_rfc3339(); 1021 let create_res = http_client 1022 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1023 .bearer_auth(&token) 1024 .json(&json!({ 1025 "repo": &did, 1026 "collection": "app.bsky.feed.post", 1027 "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now } 1028 })) 1029 .send() 1030 .await 1031 .unwrap(); 1032 assert_eq!( 1033 create_res.status(), 1034 StatusCode::OK, 1035 "Should allow creating posts with repo:app.bsky.feed.post?action=create" 1036 ); 1037 let body: Value = create_res.json().await.unwrap(); 1038 let uri = body["uri"].as_str().expect("Should have uri"); 1039 let rkey = uri.split('/').next_back().unwrap(); 1040 let delete_res = http_client 1041 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 1042 .bearer_auth(&token) 1043 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) 1044 .send() 1045 .await 1046 .unwrap(); 1047 assert_eq!( 1048 delete_res.status(), 1049 StatusCode::FORBIDDEN, 1050 "Should NOT allow deleting with create-only scope" 1051 ); 1052 let like_res = http_client 1053 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1054 .bearer_auth(&token) 1055 .json(&json!({ 1056 "repo": &did, 1057 "collection": "app.bsky.feed.like", 1058 "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now } 1059 })) 1060 .send().await.unwrap(); 1061 assert_eq!( 1062 like_res.status(), 1063 StatusCode::FORBIDDEN, 1064 "Should NOT allow creating likes (wrong collection)" 1065 ); 1066} 1067 1068#[tokio::test] 1069async fn test_granular_scope_wildcard_collection() { 1070 let url = base_url().await; 1071 let http_client = client(); 1072 let (token, did, _) = get_oauth_token_with_scope( 1073 "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*", 1074 ) 1075 .await; 1076 let now = chrono::Utc::now().to_rfc3339(); 1077 let post_res = http_client 1078 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1079 .bearer_auth(&token) 1080 .json(&json!({ 1081 "repo": &did, 1082 "collection": "app.bsky.feed.post", 1083 "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now } 1084 })) 1085 .send() 1086 .await 1087 .unwrap(); 1088 assert_eq!( 1089 post_res.status(), 1090 StatusCode::OK, 1091 "Should allow app.bsky.feed.post with app.bsky.* scope" 1092 ); 1093 let body: Value = post_res.json().await.unwrap(); 1094 let uri = body["uri"].as_str().unwrap(); 1095 let rkey = uri.split('/').next_back().unwrap(); 1096 let delete_res = http_client 1097 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 1098 .bearer_auth(&token) 1099 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) 1100 .send() 1101 .await 1102 .unwrap(); 1103 assert_eq!( 1104 delete_res.status(), 1105 StatusCode::OK, 1106 "Should allow delete with action=delete" 1107 ); 1108 let other_res = http_client 1109 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1110 .bearer_auth(&token) 1111 .json(&json!({ 1112 "repo": &did, 1113 "collection": "com.example.record", 1114 "record": { "$type": "com.example.record", "data": "test", "createdAt": now } 1115 })) 1116 .send() 1117 .await 1118 .unwrap(); 1119 assert_eq!( 1120 other_res.status(), 1121 StatusCode::FORBIDDEN, 1122 "Should NOT allow com.example.* with app.bsky.* scope" 1123 ); 1124} 1125 1126#[tokio::test] 1127async fn test_granular_scope_email_read() { 1128 let url = base_url().await; 1129 let http_client = client(); 1130 let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await; 1131 let session_res = http_client 1132 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1133 .bearer_auth(&token) 1134 .send() 1135 .await 1136 .unwrap(); 1137 assert_eq!(session_res.status(), StatusCode::OK); 1138 let body: Value = session_res.json().await.unwrap(); 1139 assert_eq!(body["did"], did); 1140 assert!( 1141 body["email"].is_string(), 1142 "Email should be visible with account:email?action=read. Got: {:?}", 1143 body 1144 ); 1145} 1146 1147#[tokio::test] 1148async fn test_granular_scope_no_email_access() { 1149 let url = base_url().await; 1150 let http_client = client(); 1151 let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await; 1152 let session_res = http_client 1153 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1154 .bearer_auth(&token) 1155 .send() 1156 .await 1157 .unwrap(); 1158 assert_eq!(session_res.status(), StatusCode::OK); 1159 let body: Value = session_res.json().await.unwrap(); 1160 assert_eq!(body["did"], did); 1161 assert!( 1162 body["email"].is_null() || body.get("email").is_none(), 1163 "Email should be hidden without account:email scope. Got: {:?}", 1164 body["email"] 1165 ); 1166} 1167 1168#[tokio::test] 1169async fn test_granular_scope_rpc_specific_method() { 1170 let url = base_url().await; 1171 let http_client = client(); 1172 let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await; 1173 let allowed_res = http_client 1174 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 1175 .bearer_auth(&token) 1176 .query(&[ 1177 ("aud", "did:web:api.bsky.app"), 1178 ("lxm", "app.bsky.feed.getTimeline"), 1179 ]) 1180 .send() 1181 .await 1182 .unwrap(); 1183 assert_eq!( 1184 allowed_res.status(), 1185 StatusCode::OK, 1186 "Should allow getServiceAuth for app.bsky.feed.getTimeline" 1187 ); 1188 let body: Value = allowed_res.json().await.unwrap(); 1189 assert!(body["token"].is_string(), "Should return service token"); 1190 let blocked_res = http_client 1191 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 1192 .bearer_auth(&token) 1193 .query(&[ 1194 ("aud", "did:web:api.bsky.app"), 1195 ("lxm", "app.bsky.feed.getAuthorFeed"), 1196 ]) 1197 .send() 1198 .await 1199 .unwrap(); 1200 assert_eq!( 1201 blocked_res.status(), 1202 StatusCode::FORBIDDEN, 1203 "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed" 1204 ); 1205 let blocked_body: Value = blocked_res.json().await.unwrap(); 1206 assert!( 1207 blocked_body["error"] 1208 .as_str() 1209 .unwrap_or("") 1210 .contains("Scope") 1211 || blocked_body["message"] 1212 .as_str() 1213 .unwrap_or("") 1214 .contains("scope"), 1215 "Should mention scope restriction: {:?}", 1216 blocked_body 1217 ); 1218 let no_lxm_res = http_client 1219 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 1220 .bearer_auth(&token) 1221 .query(&[("aud", "did:web:api.bsky.app")]) 1222 .send() 1223 .await 1224 .unwrap(); 1225 assert_eq!( 1226 no_lxm_res.status(), 1227 StatusCode::BAD_REQUEST, 1228 "Should require lxm parameter for granular scopes" 1229 ); 1230}