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_db_connection_string}; 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 ts = Utc::now().timestamp_millis(); 195 let handle = format!("oauth-test-{}", ts); 196 let email = format!("oauth-test-{}@example.com", ts); 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-{}", ts); 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 ts = Utc::now().timestamp_millis(); 353 let handle = format!("wrong-creds-{}", ts); 354 let email = format!("wrong-creds-{}@example.com", ts); 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 ts = Utc::now().timestamp_millis(); 439 let handle = format!("2fa-test-{}", ts); 440 let email = format!("2fa-test-{}@example.com", ts); 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 db_url = get_db_connection_string().await; 453 let pool = sqlx::postgres::PgPoolOptions::new() 454 .max_connections(1) 455 .connect(&db_url) 456 .await 457 .unwrap(); 458 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") 459 .bind(user_did) 460 .execute(&pool) 461 .await 462 .unwrap(); 463 let redirect_uri = "https://example.com/2fa-callback"; 464 let mock_client = setup_mock_client_metadata(redirect_uri).await; 465 let client_id = mock_client.uri(); 466 let (code_verifier, code_challenge) = generate_pkce(); 467 let par_body: Value = http_client 468 .post(format!("{}/oauth/par", url)) 469 .form(&[ 470 ("response_type", "code"), 471 ("client_id", &client_id), 472 ("redirect_uri", redirect_uri), 473 ("code_challenge", &code_challenge), 474 ("code_challenge_method", "S256"), 475 ]) 476 .send() 477 .await 478 .unwrap() 479 .json() 480 .await 481 .unwrap(); 482 let request_uri = par_body["request_uri"].as_str().unwrap(); 483 let auth_res = http_client 484 .post(format!("{}/oauth/authorize", url)) 485 .header("Content-Type", "application/json") 486 .header("Accept", "application/json") 487 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 488 .send().await.unwrap(); 489 assert_eq!( 490 auth_res.status(), 491 StatusCode::OK, 492 "Should return OK with needs_2fa" 493 ); 494 let auth_body: Value = auth_res.json().await.unwrap(); 495 assert!( 496 auth_body["needs_2fa"].as_bool().unwrap_or(false), 497 "Should need 2FA, got: {:?}", 498 auth_body 499 ); 500 let twofa_invalid = http_client 501 .post(format!("{}/oauth/authorize/2fa", url)) 502 .header("Content-Type", "application/json") 503 .json(&json!({"request_uri": request_uri, "code": "000000"})) 504 .send() 505 .await 506 .unwrap(); 507 assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN); 508 let body: Value = twofa_invalid.json().await.unwrap(); 509 assert!( 510 body["error_description"] 511 .as_str() 512 .unwrap_or("") 513 .contains("Invalid") 514 || body["error"].as_str().unwrap_or("") == "invalid_code" 515 ); 516 let twofa_code: String = 517 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") 518 .bind(request_uri) 519 .fetch_one(&pool) 520 .await 521 .unwrap(); 522 let twofa_res = http_client 523 .post(format!("{}/oauth/authorize/2fa", url)) 524 .header("Content-Type", "application/json") 525 .json(&json!({"request_uri": request_uri, "code": &twofa_code})) 526 .send() 527 .await 528 .unwrap(); 529 assert_eq!( 530 twofa_res.status(), 531 StatusCode::OK, 532 "Valid 2FA code should succeed" 533 ); 534 let twofa_body: Value = twofa_res.json().await.unwrap(); 535 let final_location = twofa_body["redirect_uri"].as_str().unwrap(); 536 assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); 537 let auth_code = final_location 538 .split("code=") 539 .nth(1) 540 .unwrap() 541 .split('&') 542 .next() 543 .unwrap(); 544 let token_res = http_client 545 .post(format!("{}/oauth/token", url)) 546 .form(&[ 547 ("grant_type", "authorization_code"), 548 ("code", auth_code), 549 ("redirect_uri", redirect_uri), 550 ("code_verifier", &code_verifier), 551 ("client_id", &client_id), 552 ]) 553 .send() 554 .await 555 .unwrap(); 556 assert_eq!(token_res.status(), StatusCode::OK); 557 let token_body: Value = token_res.json().await.unwrap(); 558 assert_eq!(token_body["sub"], user_did); 559} 560 561#[tokio::test] 562async fn test_oauth_2fa_lockout() { 563 let url = base_url().await; 564 let http_client = client(); 565 let ts = Utc::now().timestamp_millis(); 566 let handle = format!("2fa-lockout-{}", ts); 567 let email = format!("2fa-lockout-{}@example.com", ts); 568 let password = "Twofa123test!"; 569 let create_res = http_client 570 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 571 .json(&json!({ "handle": handle, "email": email, "password": password })) 572 .send() 573 .await 574 .unwrap(); 575 let account: Value = create_res.json().await.unwrap(); 576 let user_did = account["did"].as_str().unwrap(); 577 verify_new_account(&http_client, user_did).await; 578 let db_url = get_db_connection_string().await; 579 let pool = sqlx::postgres::PgPoolOptions::new() 580 .max_connections(1) 581 .connect(&db_url) 582 .await 583 .unwrap(); 584 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") 585 .bind(user_did) 586 .execute(&pool) 587 .await 588 .unwrap(); 589 let redirect_uri = "https://example.com/2fa-lockout-callback"; 590 let mock_client = setup_mock_client_metadata(redirect_uri).await; 591 let client_id = mock_client.uri(); 592 let (_, code_challenge) = generate_pkce(); 593 let par_body: Value = http_client 594 .post(format!("{}/oauth/par", url)) 595 .form(&[ 596 ("response_type", "code"), 597 ("client_id", &client_id), 598 ("redirect_uri", redirect_uri), 599 ("code_challenge", &code_challenge), 600 ("code_challenge_method", "S256"), 601 ]) 602 .send() 603 .await 604 .unwrap() 605 .json() 606 .await 607 .unwrap(); 608 let request_uri = par_body["request_uri"].as_str().unwrap(); 609 let auth_res = http_client 610 .post(format!("{}/oauth/authorize", url)) 611 .header("Content-Type", "application/json") 612 .header("Accept", "application/json") 613 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 614 .send().await.unwrap(); 615 assert_eq!( 616 auth_res.status(), 617 StatusCode::OK, 618 "Should return OK with needs_2fa" 619 ); 620 let auth_body: Value = auth_res.json().await.unwrap(); 621 assert!( 622 auth_body["needs_2fa"].as_bool().unwrap_or(false), 623 "Should need 2FA" 624 ); 625 for i in 0..5 { 626 let res = http_client 627 .post(format!("{}/oauth/authorize/2fa", url)) 628 .header("Content-Type", "application/json") 629 .json(&json!({"request_uri": request_uri, "code": "999999"})) 630 .send() 631 .await 632 .unwrap(); 633 if i < 4 { 634 assert_eq!( 635 res.status(), 636 StatusCode::FORBIDDEN, 637 "Attempt {} should return 403", 638 i 639 ); 640 } 641 } 642 let lockout_res = http_client 643 .post(format!("{}/oauth/authorize/2fa", url)) 644 .header("Content-Type", "application/json") 645 .json(&json!({"request_uri": request_uri, "code": "999999"})) 646 .send() 647 .await 648 .unwrap(); 649 let body: Value = lockout_res.json().await.unwrap(); 650 let desc = body["error_description"].as_str().unwrap_or(""); 651 assert!( 652 desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request", 653 "Expected lockout error, got: {:?}", 654 body 655 ); 656} 657 658#[tokio::test] 659async fn test_account_selector_with_2fa() { 660 let url = base_url().await; 661 let http_client = client(); 662 let ts = Utc::now().timestamp_millis(); 663 let handle = format!("selector-2fa-{}", ts); 664 let email = format!("selector-2fa-{}@example.com", ts); 665 let password = "Selector2fa123!"; 666 let create_res = http_client 667 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 668 .json(&json!({ "handle": handle, "email": email, "password": password })) 669 .send() 670 .await 671 .unwrap(); 672 let account: Value = create_res.json().await.unwrap(); 673 let user_did = account["did"].as_str().unwrap().to_string(); 674 verify_new_account(&http_client, &user_did).await; 675 let redirect_uri = "https://example.com/selector-2fa-callback"; 676 let mock_client = setup_mock_client_metadata(redirect_uri).await; 677 let client_id = mock_client.uri(); 678 let (code_verifier, code_challenge) = generate_pkce(); 679 let par_body: Value = http_client 680 .post(format!("{}/oauth/par", url)) 681 .form(&[ 682 ("response_type", "code"), 683 ("client_id", &client_id), 684 ("redirect_uri", redirect_uri), 685 ("code_challenge", &code_challenge), 686 ("code_challenge_method", "S256"), 687 ]) 688 .send() 689 .await 690 .unwrap() 691 .json() 692 .await 693 .unwrap(); 694 let request_uri = par_body["request_uri"].as_str().unwrap(); 695 let auth_res = http_client 696 .post(format!("{}/oauth/authorize", url)) 697 .header("Content-Type", "application/json") 698 .header("Accept", "application/json") 699 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true})) 700 .send().await.unwrap(); 701 assert_eq!( 702 auth_res.status(), 703 StatusCode::OK, 704 "Expected OK with JSON response" 705 ); 706 let device_cookie = auth_res 707 .headers() 708 .get("set-cookie") 709 .and_then(|v| v.to_str().ok()) 710 .map(|s| s.split(';').next().unwrap_or("").to_string()) 711 .expect("Should have device cookie"); 712 let auth_body: Value = auth_res.json().await.unwrap(); 713 let mut location = auth_body["redirect_uri"] 714 .as_str() 715 .expect("Expected redirect_uri") 716 .to_string(); 717 if location.contains("/oauth/consent") { 718 let consent_res = http_client 719 .post(format!("{}/oauth/authorize/consent", url)) 720 .header("Content-Type", "application/json") 721 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": true})) 722 .send().await.unwrap(); 723 assert_eq!( 724 consent_res.status(), 725 StatusCode::OK, 726 "Consent should succeed" 727 ); 728 let consent_body: Value = consent_res.json().await.unwrap(); 729 location = consent_body["redirect_uri"] 730 .as_str() 731 .expect("Expected redirect_uri from consent") 732 .to_string(); 733 } 734 assert!(location.contains("code=")); 735 let code = location 736 .split("code=") 737 .nth(1) 738 .unwrap() 739 .split('&') 740 .next() 741 .unwrap(); 742 let _ = http_client 743 .post(format!("{}/oauth/token", url)) 744 .form(&[ 745 ("grant_type", "authorization_code"), 746 ("code", code), 747 ("redirect_uri", redirect_uri), 748 ("code_verifier", &code_verifier), 749 ("client_id", &client_id), 750 ]) 751 .send() 752 .await 753 .unwrap() 754 .json::<Value>() 755 .await 756 .unwrap(); 757 let db_url = get_db_connection_string().await; 758 let pool = sqlx::postgres::PgPoolOptions::new() 759 .max_connections(1) 760 .connect(&db_url) 761 .await 762 .unwrap(); 763 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") 764 .bind(&user_did) 765 .execute(&pool) 766 .await 767 .unwrap(); 768 let (code_verifier2, code_challenge2) = generate_pkce(); 769 let par_body2: Value = http_client 770 .post(format!("{}/oauth/par", url)) 771 .form(&[ 772 ("response_type", "code"), 773 ("client_id", &client_id), 774 ("redirect_uri", redirect_uri), 775 ("code_challenge", &code_challenge2), 776 ("code_challenge_method", "S256"), 777 ]) 778 .send() 779 .await 780 .unwrap() 781 .json() 782 .await 783 .unwrap(); 784 let request_uri2 = par_body2["request_uri"].as_str().unwrap(); 785 let select_res = http_client 786 .post(format!("{}/oauth/authorize/select", url)) 787 .header("cookie", &device_cookie) 788 .header("Content-Type", "application/json") 789 .json(&json!({"request_uri": request_uri2, "did": &user_did})) 790 .send() 791 .await 792 .unwrap(); 793 assert_eq!( 794 select_res.status(), 795 StatusCode::OK, 796 "Select should return OK with JSON" 797 ); 798 let select_body: Value = select_res.json().await.unwrap(); 799 assert!( 800 select_body["needs_2fa"].as_bool().unwrap_or(false), 801 "Should need 2FA" 802 ); 803 let twofa_code: String = 804 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") 805 .bind(request_uri2) 806 .fetch_one(&pool) 807 .await 808 .unwrap(); 809 let twofa_res = http_client 810 .post(format!("{}/oauth/authorize/2fa", url)) 811 .header("cookie", &device_cookie) 812 .header("Content-Type", "application/json") 813 .json(&json!({"request_uri": request_uri2, "code": &twofa_code})) 814 .send() 815 .await 816 .unwrap(); 817 assert_eq!( 818 twofa_res.status(), 819 StatusCode::OK, 820 "Valid 2FA should succeed" 821 ); 822 let twofa_body: Value = twofa_res.json().await.unwrap(); 823 let final_location = twofa_body["redirect_uri"].as_str().unwrap(); 824 assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); 825 let final_code = final_location 826 .split("code=") 827 .nth(1) 828 .unwrap() 829 .split('&') 830 .next() 831 .unwrap(); 832 let token_res = http_client 833 .post(format!("{}/oauth/token", url)) 834 .form(&[ 835 ("grant_type", "authorization_code"), 836 ("code", final_code), 837 ("redirect_uri", redirect_uri), 838 ("code_verifier", &code_verifier2), 839 ("client_id", &client_id), 840 ]) 841 .send() 842 .await 843 .unwrap(); 844 assert_eq!(token_res.status(), StatusCode::OK); 845 let final_token: Value = token_res.json().await.unwrap(); 846 assert_eq!(final_token["sub"], user_did); 847} 848 849#[tokio::test] 850async fn test_oauth_state_encoding() { 851 let url = base_url().await; 852 let http_client = client(); 853 let ts = Utc::now().timestamp_millis(); 854 let handle = format!("state-special-{}", ts); 855 let email = format!("state-special-{}@example.com", ts); 856 let password = "State123special!"; 857 let create_res = http_client 858 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 859 .json(&json!({ "handle": handle, "email": email, "password": password })) 860 .send() 861 .await 862 .unwrap(); 863 let account: Value = create_res.json().await.unwrap(); 864 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 865 let redirect_uri = "https://example.com/state-special-callback"; 866 let mock_client = setup_mock_client_metadata(redirect_uri).await; 867 let client_id = mock_client.uri(); 868 let (_, code_challenge) = generate_pkce(); 869 let special_state = "state=with&special=chars&plus+more"; 870 let par_body: Value = http_client 871 .post(format!("{}/oauth/par", url)) 872 .form(&[ 873 ("response_type", "code"), 874 ("client_id", &client_id), 875 ("redirect_uri", redirect_uri), 876 ("code_challenge", &code_challenge), 877 ("code_challenge_method", "S256"), 878 ("state", special_state), 879 ]) 880 .send() 881 .await 882 .unwrap() 883 .json() 884 .await 885 .unwrap(); 886 let request_uri = par_body["request_uri"].as_str().unwrap(); 887 let auth_res = http_client 888 .post(format!("{}/oauth/authorize", url)) 889 .header("Content-Type", "application/json") 890 .header("Accept", "application/json") 891 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 892 .send().await.unwrap(); 893 assert_eq!( 894 auth_res.status(), 895 StatusCode::OK, 896 "Expected OK with JSON response" 897 ); 898 let auth_body: Value = auth_res.json().await.unwrap(); 899 let mut location = auth_body["redirect_uri"] 900 .as_str() 901 .expect("Expected redirect_uri") 902 .to_string(); 903 if location.contains("/oauth/consent") { 904 let consent_res = http_client 905 .post(format!("{}/oauth/authorize/consent", url)) 906 .header("Content-Type", "application/json") 907 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 908 .send().await.unwrap(); 909 assert_eq!( 910 consent_res.status(), 911 StatusCode::OK, 912 "Consent should succeed" 913 ); 914 let consent_body: Value = consent_res.json().await.unwrap(); 915 location = consent_body["redirect_uri"] 916 .as_str() 917 .expect("Expected redirect_uri from consent") 918 .to_string(); 919 } 920 assert!(location.contains("state=")); 921 let encoded_state = urlencoding::encode(special_state); 922 assert!( 923 location.contains(&format!("state={}", encoded_state)), 924 "State should be URL-encoded. Got: {}", 925 location 926 ); 927} 928 929async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) { 930 let url = base_url().await; 931 let http_client = client(); 932 let ts = Utc::now().timestamp_millis(); 933 let handle = format!("scope-test-{}", ts); 934 let email = format!("scope-test-{}@example.com", ts); 935 let password = "Scopetest123!"; 936 let create_res = http_client 937 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 938 .json(&json!({ "handle": handle, "email": email, "password": password })) 939 .send() 940 .await 941 .unwrap(); 942 assert_eq!(create_res.status(), StatusCode::OK); 943 let account: Value = create_res.json().await.unwrap(); 944 let user_did = account["did"].as_str().unwrap().to_string(); 945 verify_new_account(&http_client, &user_did).await; 946 let redirect_uri = "https://example.com/scope-callback"; 947 let mock_client = setup_mock_client_metadata(redirect_uri).await; 948 let client_id = mock_client.uri(); 949 let (code_verifier, code_challenge) = generate_pkce(); 950 let par_res = http_client 951 .post(format!("{}/oauth/par", url)) 952 .form(&[ 953 ("response_type", "code"), 954 ("client_id", &client_id), 955 ("redirect_uri", redirect_uri), 956 ("code_challenge", &code_challenge), 957 ("code_challenge_method", "S256"), 958 ("scope", scope), 959 ("state", "test"), 960 ]) 961 .send() 962 .await 963 .unwrap(); 964 assert_eq!( 965 par_res.status(), 966 StatusCode::CREATED, 967 "PAR should succeed for scope: {}", 968 scope 969 ); 970 let par_body: Value = par_res.json().await.unwrap(); 971 let request_uri = par_body["request_uri"].as_str().unwrap(); 972 let auth_res = http_client 973 .post(format!("{}/oauth/authorize", url)) 974 .header("Content-Type", "application/json") 975 .header("Accept", "application/json") 976 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) 977 .send().await.unwrap(); 978 assert_eq!(auth_res.status(), StatusCode::OK); 979 let auth_body: Value = auth_res.json().await.unwrap(); 980 let mut location = auth_body["redirect_uri"] 981 .as_str() 982 .expect("Expected redirect_uri") 983 .to_string(); 984 if location.contains("/oauth/consent") { 985 let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); 986 let consent_res = http_client 987 .post(format!("{}/oauth/authorize/consent", url)) 988 .header("Content-Type", "application/json") 989 .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false})) 990 .send().await.unwrap(); 991 let consent_status = consent_res.status(); 992 let consent_body: Value = consent_res.json().await.unwrap(); 993 assert_eq!( 994 consent_status, 995 StatusCode::OK, 996 "Consent should succeed. Scope: {}, Body: {:?}", 997 scope, 998 consent_body 999 ); 1000 location = consent_body["redirect_uri"] 1001 .as_str() 1002 .expect("Expected redirect_uri from consent") 1003 .to_string(); 1004 } 1005 let code = location 1006 .split("code=") 1007 .nth(1) 1008 .unwrap() 1009 .split('&') 1010 .next() 1011 .unwrap(); 1012 let token_res = http_client 1013 .post(format!("{}/oauth/token", url)) 1014 .form(&[ 1015 ("grant_type", "authorization_code"), 1016 ("code", code), 1017 ("redirect_uri", redirect_uri), 1018 ("code_verifier", &code_verifier), 1019 ("client_id", &client_id), 1020 ]) 1021 .send() 1022 .await 1023 .unwrap(); 1024 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); 1025 let token_body: Value = token_res.json().await.unwrap(); 1026 let access_token = token_body["access_token"].as_str().unwrap().to_string(); 1027 (access_token, user_did, handle) 1028} 1029 1030#[tokio::test] 1031async fn test_granular_scope_repo_create_only() { 1032 let url = base_url().await; 1033 let http_client = client(); 1034 let (token, did, _) = 1035 get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await; 1036 let now = chrono::Utc::now().to_rfc3339(); 1037 let create_res = http_client 1038 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1039 .bearer_auth(&token) 1040 .json(&json!({ 1041 "repo": &did, 1042 "collection": "app.bsky.feed.post", 1043 "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now } 1044 })) 1045 .send() 1046 .await 1047 .unwrap(); 1048 assert_eq!( 1049 create_res.status(), 1050 StatusCode::OK, 1051 "Should allow creating posts with repo:app.bsky.feed.post?action=create" 1052 ); 1053 let body: Value = create_res.json().await.unwrap(); 1054 let uri = body["uri"].as_str().expect("Should have uri"); 1055 let rkey = uri.split('/').last().unwrap(); 1056 let delete_res = http_client 1057 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 1058 .bearer_auth(&token) 1059 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) 1060 .send() 1061 .await 1062 .unwrap(); 1063 assert_eq!( 1064 delete_res.status(), 1065 StatusCode::FORBIDDEN, 1066 "Should NOT allow deleting with create-only scope" 1067 ); 1068 let like_res = http_client 1069 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1070 .bearer_auth(&token) 1071 .json(&json!({ 1072 "repo": &did, 1073 "collection": "app.bsky.feed.like", 1074 "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now } 1075 })) 1076 .send().await.unwrap(); 1077 assert_eq!( 1078 like_res.status(), 1079 StatusCode::FORBIDDEN, 1080 "Should NOT allow creating likes (wrong collection)" 1081 ); 1082} 1083 1084#[tokio::test] 1085async fn test_granular_scope_wildcard_collection() { 1086 let url = base_url().await; 1087 let http_client = client(); 1088 let (token, did, _) = get_oauth_token_with_scope( 1089 "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*", 1090 ) 1091 .await; 1092 let now = chrono::Utc::now().to_rfc3339(); 1093 let post_res = http_client 1094 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1095 .bearer_auth(&token) 1096 .json(&json!({ 1097 "repo": &did, 1098 "collection": "app.bsky.feed.post", 1099 "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now } 1100 })) 1101 .send() 1102 .await 1103 .unwrap(); 1104 assert_eq!( 1105 post_res.status(), 1106 StatusCode::OK, 1107 "Should allow app.bsky.feed.post with app.bsky.* scope" 1108 ); 1109 let body: Value = post_res.json().await.unwrap(); 1110 let uri = body["uri"].as_str().unwrap(); 1111 let rkey = uri.split('/').last().unwrap(); 1112 let delete_res = http_client 1113 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) 1114 .bearer_auth(&token) 1115 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) 1116 .send() 1117 .await 1118 .unwrap(); 1119 assert_eq!( 1120 delete_res.status(), 1121 StatusCode::OK, 1122 "Should allow delete with action=delete" 1123 ); 1124 let other_res = http_client 1125 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1126 .bearer_auth(&token) 1127 .json(&json!({ 1128 "repo": &did, 1129 "collection": "com.example.record", 1130 "record": { "$type": "com.example.record", "data": "test", "createdAt": now } 1131 })) 1132 .send() 1133 .await 1134 .unwrap(); 1135 assert_eq!( 1136 other_res.status(), 1137 StatusCode::FORBIDDEN, 1138 "Should NOT allow com.example.* with app.bsky.* scope" 1139 ); 1140} 1141 1142#[tokio::test] 1143async fn test_granular_scope_email_read() { 1144 let url = base_url().await; 1145 let http_client = client(); 1146 let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await; 1147 let session_res = http_client 1148 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1149 .bearer_auth(&token) 1150 .send() 1151 .await 1152 .unwrap(); 1153 assert_eq!(session_res.status(), StatusCode::OK); 1154 let body: Value = session_res.json().await.unwrap(); 1155 assert_eq!(body["did"], did); 1156 assert!( 1157 body["email"].is_string(), 1158 "Email should be visible with account:email?action=read. Got: {:?}", 1159 body 1160 ); 1161} 1162 1163#[tokio::test] 1164async fn test_granular_scope_no_email_access() { 1165 let url = base_url().await; 1166 let http_client = client(); 1167 let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await; 1168 let session_res = http_client 1169 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 1170 .bearer_auth(&token) 1171 .send() 1172 .await 1173 .unwrap(); 1174 assert_eq!(session_res.status(), StatusCode::OK); 1175 let body: Value = session_res.json().await.unwrap(); 1176 assert_eq!(body["did"], did); 1177 assert!( 1178 body["email"].is_null() || body.get("email").is_none(), 1179 "Email should be hidden without account:email scope. Got: {:?}", 1180 body["email"] 1181 ); 1182} 1183 1184#[tokio::test] 1185async fn test_granular_scope_rpc_specific_method() { 1186 let url = base_url().await; 1187 let http_client = client(); 1188 let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await; 1189 let allowed_res = http_client 1190 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 1191 .bearer_auth(&token) 1192 .query(&[ 1193 ("aud", "did:web:api.bsky.app"), 1194 ("lxm", "app.bsky.feed.getTimeline"), 1195 ]) 1196 .send() 1197 .await 1198 .unwrap(); 1199 assert_eq!( 1200 allowed_res.status(), 1201 StatusCode::OK, 1202 "Should allow getServiceAuth for app.bsky.feed.getTimeline" 1203 ); 1204 let body: Value = allowed_res.json().await.unwrap(); 1205 assert!(body["token"].is_string(), "Should return service token"); 1206 let blocked_res = http_client 1207 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 1208 .bearer_auth(&token) 1209 .query(&[ 1210 ("aud", "did:web:api.bsky.app"), 1211 ("lxm", "app.bsky.feed.getAuthorFeed"), 1212 ]) 1213 .send() 1214 .await 1215 .unwrap(); 1216 assert_eq!( 1217 blocked_res.status(), 1218 StatusCode::FORBIDDEN, 1219 "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed" 1220 ); 1221 let blocked_body: Value = blocked_res.json().await.unwrap(); 1222 assert!( 1223 blocked_body["error"] 1224 .as_str() 1225 .unwrap_or("") 1226 .contains("Scope") 1227 || blocked_body["message"] 1228 .as_str() 1229 .unwrap_or("") 1230 .contains("scope"), 1231 "Should mention scope restriction: {:?}", 1232 blocked_body 1233 ); 1234 let no_lxm_res = http_client 1235 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 1236 .bearer_auth(&token) 1237 .query(&[("aud", "did:web:api.bsky.app")]) 1238 .send() 1239 .await 1240 .unwrap(); 1241 assert_eq!( 1242 no_lxm_res.status(), 1243 StatusCode::BAD_REQUEST, 1244 "Should require lxm parameter for granular scopes" 1245 ); 1246}