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