this repo has no description
1#![allow(unused_imports)] 2mod common; 3mod helpers; 4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5use chrono::Utc; 6use common::{base_url, client, create_account_and_login}; 7use helpers::verify_new_account; 8use reqwest::StatusCode; 9use serde_json::{Value, json}; 10use sha2::{Digest, Sha256}; 11use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint}; 12use wiremock::matchers::{method, path}; 13use wiremock::{Mock, MockServer, ResponseTemplate}; 14 15fn generate_pkce() -> (String, String) { 16 let verifier_bytes: [u8; 32] = rand::random(); 17 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 18 let mut hasher = Sha256::new(); 19 hasher.update(code_verifier.as_bytes()); 20 let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); 21 (code_verifier, code_challenge) 22} 23 24async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 25 let mock_server = MockServer::start().await; 26 let metadata = json!({ 27 "client_id": mock_server.uri(), 28 "client_name": "Security Test Client", 29 "redirect_uris": [redirect_uri], 30 "grant_types": ["authorization_code", "refresh_token"], 31 "response_types": ["code"], 32 "token_endpoint_auth_method": "none", 33 "dpop_bound_access_tokens": false 34 }); 35 Mock::given(method("GET")) 36 .and(path("/")) 37 .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 38 .mount(&mock_server) 39 .await; 40 mock_server 41} 42 43async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) { 44 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 45 let handle = format!("se{}", suffix); 46 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 47 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" })) 48 .send().await.unwrap(); 49 let account: Value = create_res.json().await.unwrap(); 50 let did = account["did"].as_str().unwrap(); 51 verify_new_account(http_client, did).await; 52 let redirect_uri = "https://example.com/sec-callback"; 53 let mock_client = setup_mock_client_metadata(redirect_uri).await; 54 let client_id = mock_client.uri(); 55 let (code_verifier, code_challenge) = generate_pkce(); 56 let par_body: Value = http_client 57 .post(format!("{}/oauth/par", url)) 58 .form(&[ 59 ("response_type", "code"), 60 ("client_id", &client_id), 61 ("redirect_uri", redirect_uri), 62 ("code_challenge", &code_challenge), 63 ("code_challenge_method", "S256"), 64 ]) 65 .send() 66 .await 67 .unwrap() 68 .json() 69 .await 70 .unwrap(); 71 let request_uri = par_body["request_uri"].as_str().unwrap(); 72 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 73 .header("Content-Type", "application/json") 74 .header("Accept", "application/json") 75 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false})) 76 .send().await.unwrap(); 77 let auth_body: Value = auth_res.json().await.unwrap(); 78 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 79 if location.contains("/oauth/consent") { 80 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url)) 81 .header("Content-Type", "application/json") 82 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 83 .send().await.unwrap(); 84 let consent_body: Value = consent_res.json().await.unwrap(); 85 location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 86 } 87 let code = location 88 .split("code=") 89 .nth(1) 90 .unwrap() 91 .split('&') 92 .next() 93 .unwrap(); 94 let token_body: Value = http_client 95 .post(format!("{}/oauth/token", url)) 96 .form(&[ 97 ("grant_type", "authorization_code"), 98 ("code", code), 99 ("redirect_uri", redirect_uri), 100 ("code_verifier", &code_verifier), 101 ("client_id", &client_id), 102 ]) 103 .send() 104 .await 105 .unwrap() 106 .json() 107 .await 108 .unwrap(); 109 ( 110 token_body["access_token"].as_str().unwrap().to_string(), 111 token_body["refresh_token"].as_str().unwrap().to_string(), 112 client_id, 113 ) 114} 115 116#[tokio::test] 117async fn test_token_tampering_attacks() { 118 let url = base_url().await; 119 let http_client = client(); 120 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 121 let parts: Vec<&str> = access_token.split('.').collect(); 122 assert_eq!(parts.len(), 3); 123 let forged_sig = URL_SAFE_NO_PAD.encode([0u8; 32]); 124 let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig); 125 assert_eq!( 126 http_client 127 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 128 .bearer_auth(&forged_token) 129 .send() 130 .await 131 .unwrap() 132 .status(), 133 StatusCode::UNAUTHORIZED, 134 "Forged signature should be rejected" 135 ); 136 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 137 let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); 138 payload["sub"] = json!("did:plc:attacker"); 139 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 140 let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 141 assert_eq!( 142 http_client 143 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 144 .bearer_auth(&modified_token) 145 .send() 146 .await 147 .unwrap() 148 .status(), 149 StatusCode::UNAUTHORIZED, 150 "Modified payload should be rejected" 151 ); 152 let none_header = json!({ "alg": "none", "typ": "at+jwt" }); 153 let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds", 154 "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" }); 155 let none_token = format!( 156 "{}.{}.", 157 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()), 158 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()) 159 ); 160 assert_eq!( 161 http_client 162 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 163 .bearer_auth(&none_token) 164 .send() 165 .await 166 .unwrap() 167 .status(), 168 StatusCode::UNAUTHORIZED, 169 "alg=none should be rejected" 170 ); 171 let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" }); 172 let rs256_token = format!( 173 "{}.{}.{}", 174 URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()), 175 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), 176 URL_SAFE_NO_PAD.encode([1u8; 64]) 177 ); 178 assert_eq!( 179 http_client 180 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 181 .bearer_auth(&rs256_token) 182 .send() 183 .await 184 .unwrap() 185 .status(), 186 StatusCode::UNAUTHORIZED, 187 "Algorithm substitution should be rejected" 188 ); 189 let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds", 190 "iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" }); 191 let expired_token = format!( 192 "{}.{}.{}", 193 URL_SAFE_NO_PAD 194 .encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()), 195 URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), 196 URL_SAFE_NO_PAD.encode([1u8; 32]) 197 ); 198 assert_eq!( 199 http_client 200 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 201 .bearer_auth(&expired_token) 202 .send() 203 .await 204 .unwrap() 205 .status(), 206 StatusCode::UNAUTHORIZED, 207 "Expired token should be rejected" 208 ); 209} 210 211#[tokio::test] 212async fn test_pkce_security() { 213 let url = base_url().await; 214 let http_client = client(); 215 let redirect_uri = "https://example.com/pkce-callback"; 216 let mock_client = setup_mock_client_metadata(redirect_uri).await; 217 let client_id = mock_client.uri(); 218 let res = http_client 219 .post(format!("{}/oauth/par", url)) 220 .form(&[ 221 ("response_type", "code"), 222 ("client_id", &client_id), 223 ("redirect_uri", redirect_uri), 224 ("code_challenge", "plain-text-challenge"), 225 ("code_challenge_method", "plain"), 226 ]) 227 .send() 228 .await 229 .unwrap(); 230 assert_eq!( 231 res.status(), 232 StatusCode::BAD_REQUEST, 233 "PKCE plain method should be rejected" 234 ); 235 let body: Value = res.json().await.unwrap(); 236 assert!( 237 body["error_description"] 238 .as_str() 239 .unwrap() 240 .to_lowercase() 241 .contains("s256") 242 ); 243 let res = http_client 244 .post(format!("{}/oauth/par", url)) 245 .form(&[ 246 ("response_type", "code"), 247 ("client_id", &client_id), 248 ("redirect_uri", redirect_uri), 249 ]) 250 .send() 251 .await 252 .unwrap(); 253 assert_eq!( 254 res.status(), 255 StatusCode::BAD_REQUEST, 256 "Missing PKCE challenge should be rejected" 257 ); 258 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 259 let handle = format!("pa{}", suffix); 260 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 261 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" })) 262 .send().await.unwrap(); 263 let account: Value = create_res.json().await.unwrap(); 264 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 265 let (_, code_challenge) = generate_pkce(); 266 let (attacker_verifier, _) = generate_pkce(); 267 let par_body: Value = http_client 268 .post(format!("{}/oauth/par", url)) 269 .form(&[ 270 ("response_type", "code"), 271 ("client_id", &client_id), 272 ("redirect_uri", redirect_uri), 273 ("code_challenge", &code_challenge), 274 ("code_challenge_method", "S256"), 275 ]) 276 .send() 277 .await 278 .unwrap() 279 .json() 280 .await 281 .unwrap(); 282 let request_uri = par_body["request_uri"].as_str().unwrap(); 283 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 284 .header("Content-Type", "application/json") 285 .header("Accept", "application/json") 286 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false})) 287 .send().await.unwrap(); 288 assert_eq!(auth_res.status(), StatusCode::OK); 289 let auth_body: Value = auth_res.json().await.unwrap(); 290 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 291 if location.contains("/oauth/consent") { 292 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url)) 293 .header("Content-Type", "application/json") 294 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 295 .send().await.unwrap(); 296 let consent_body: Value = consent_res.json().await.unwrap(); 297 location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 298 } 299 let code = location 300 .split("code=") 301 .nth(1) 302 .unwrap() 303 .split('&') 304 .next() 305 .unwrap(); 306 let token_res = http_client 307 .post(format!("{}/oauth/token", url)) 308 .form(&[ 309 ("grant_type", "authorization_code"), 310 ("code", code), 311 ("redirect_uri", redirect_uri), 312 ("code_verifier", &attacker_verifier), 313 ("client_id", &client_id), 314 ]) 315 .send() 316 .await 317 .unwrap(); 318 assert_eq!( 319 token_res.status(), 320 StatusCode::BAD_REQUEST, 321 "Wrong PKCE verifier should be rejected" 322 ); 323} 324 325#[tokio::test] 326async fn test_replay_attacks() { 327 let url = base_url().await; 328 let http_client = client(); 329 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 330 let handle = format!("rp{}", suffix); 331 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 332 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" })) 333 .send().await.unwrap(); 334 let account: Value = create_res.json().await.unwrap(); 335 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 336 let redirect_uri = "https://example.com/replay-callback"; 337 let mock_client = setup_mock_client_metadata(redirect_uri).await; 338 let client_id = mock_client.uri(); 339 let (code_verifier, code_challenge) = generate_pkce(); 340 let par_body: Value = http_client 341 .post(format!("{}/oauth/par", url)) 342 .form(&[ 343 ("response_type", "code"), 344 ("client_id", &client_id), 345 ("redirect_uri", redirect_uri), 346 ("code_challenge", &code_challenge), 347 ("code_challenge_method", "S256"), 348 ]) 349 .send() 350 .await 351 .unwrap() 352 .json() 353 .await 354 .unwrap(); 355 let request_uri = par_body["request_uri"].as_str().unwrap(); 356 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 357 .header("Content-Type", "application/json") 358 .header("Accept", "application/json") 359 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false})) 360 .send().await.unwrap(); 361 assert_eq!(auth_res.status(), StatusCode::OK); 362 let auth_body: Value = auth_res.json().await.unwrap(); 363 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 364 if location.contains("/oauth/consent") { 365 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url)) 366 .header("Content-Type", "application/json") 367 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) 368 .send().await.unwrap(); 369 let consent_body: Value = consent_res.json().await.unwrap(); 370 location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 371 } 372 let code = location 373 .split("code=") 374 .nth(1) 375 .unwrap() 376 .split('&') 377 .next() 378 .unwrap() 379 .to_string(); 380 let first = http_client 381 .post(format!("{}/oauth/token", url)) 382 .form(&[ 383 ("grant_type", "authorization_code"), 384 ("code", &code), 385 ("redirect_uri", redirect_uri), 386 ("code_verifier", &code_verifier), 387 ("client_id", &client_id), 388 ]) 389 .send() 390 .await 391 .unwrap(); 392 assert_eq!(first.status(), StatusCode::OK, "First use should succeed"); 393 let first_body: Value = first.json().await.unwrap(); 394 let replay = http_client 395 .post(format!("{}/oauth/token", url)) 396 .form(&[ 397 ("grant_type", "authorization_code"), 398 ("code", &code), 399 ("redirect_uri", redirect_uri), 400 ("code_verifier", &code_verifier), 401 ("client_id", &client_id), 402 ]) 403 .send() 404 .await 405 .unwrap(); 406 assert_eq!( 407 replay.status(), 408 StatusCode::BAD_REQUEST, 409 "Auth code replay should fail" 410 ); 411 let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string(); 412 let first_refresh: Value = http_client 413 .post(format!("{}/oauth/token", url)) 414 .form(&[ 415 ("grant_type", "refresh_token"), 416 ("refresh_token", &stolen_rt), 417 ("client_id", &client_id), 418 ]) 419 .send() 420 .await 421 .unwrap() 422 .json() 423 .await 424 .unwrap(); 425 assert!( 426 first_refresh["access_token"].is_string(), 427 "First refresh should succeed" 428 ); 429 let new_rt = first_refresh["refresh_token"].as_str().unwrap(); 430 let rt_replay = http_client 431 .post(format!("{}/oauth/token", url)) 432 .form(&[ 433 ("grant_type", "refresh_token"), 434 ("refresh_token", &stolen_rt), 435 ("client_id", &client_id), 436 ]) 437 .send() 438 .await 439 .unwrap(); 440 assert_eq!( 441 rt_replay.status(), 442 StatusCode::OK, 443 "Refresh token reuse within grace period should return existing tokens" 444 ); 445 let grace_body: Value = rt_replay.json().await.unwrap(); 446 assert_eq!( 447 grace_body["refresh_token"].as_str().unwrap(), 448 new_rt, 449 "Grace period response should return the current refresh token" 450 ); 451 let second_refresh: Value = http_client 452 .post(format!("{}/oauth/token", url)) 453 .form(&[ 454 ("grant_type", "refresh_token"), 455 ("refresh_token", new_rt), 456 ("client_id", &client_id), 457 ]) 458 .send() 459 .await 460 .unwrap() 461 .json() 462 .await 463 .unwrap(); 464 assert!( 465 second_refresh["access_token"].is_string(), 466 "Second refresh with new token should succeed" 467 ); 468 let newest_rt = second_refresh["refresh_token"].as_str().unwrap(); 469 let replay_after_rotation = http_client 470 .post(format!("{}/oauth/token", url)) 471 .form(&[ 472 ("grant_type", "refresh_token"), 473 ("refresh_token", &stolen_rt), 474 ("client_id", &client_id), 475 ]) 476 .send() 477 .await 478 .unwrap(); 479 assert_eq!( 480 replay_after_rotation.status(), 481 StatusCode::BAD_REQUEST, 482 "Replay of original token after another rotation should fail" 483 ); 484 let body: Value = replay_after_rotation.json().await.unwrap(); 485 assert!( 486 body["error_description"] 487 .as_str() 488 .unwrap() 489 .to_lowercase() 490 .contains("reuse"), 491 "Error should indicate token reuse" 492 ); 493 let family_revoked = http_client 494 .post(format!("{}/oauth/token", url)) 495 .form(&[ 496 ("grant_type", "refresh_token"), 497 ("refresh_token", newest_rt), 498 ("client_id", &client_id), 499 ]) 500 .send() 501 .await 502 .unwrap(); 503 assert_eq!( 504 family_revoked.status(), 505 StatusCode::BAD_REQUEST, 506 "Token family should be revoked after replay detection" 507 ); 508} 509 510#[tokio::test] 511async fn test_oauth_security_boundaries() { 512 let url = base_url().await; 513 let http_client = client(); 514 let registered_redirect = "https://legitimate-app.com/callback"; 515 let mock_client = setup_mock_client_metadata(registered_redirect).await; 516 let client_id = mock_client.uri(); 517 let (_, code_challenge) = generate_pkce(); 518 let res = http_client 519 .post(format!("{}/oauth/par", url)) 520 .form(&[ 521 ("response_type", "code"), 522 ("client_id", &client_id), 523 ("redirect_uri", "https://attacker.com/steal"), 524 ("code_challenge", &code_challenge), 525 ("code_challenge_method", "S256"), 526 ]) 527 .send() 528 .await 529 .unwrap(); 530 assert_eq!( 531 res.status(), 532 StatusCode::BAD_REQUEST, 533 "Unregistered redirect_uri should be rejected" 534 ); 535 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 536 let handle = format!("da{}", suffix); 537 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 538 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" })) 539 .send().await.unwrap(); 540 let account: Value = create_res.json().await.unwrap(); 541 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 542 http_client 543 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) 544 .bearer_auth(&access_jwt) 545 .json(&json!({})) 546 .send() 547 .await 548 .unwrap(); 549 let deact_par: Value = http_client 550 .post(format!("{}/oauth/par", url)) 551 .form(&[ 552 ("response_type", "code"), 553 ("client_id", &client_id), 554 ("redirect_uri", registered_redirect), 555 ("code_challenge", &code_challenge), 556 ("code_challenge_method", "S256"), 557 ]) 558 .send() 559 .await 560 .unwrap() 561 .json() 562 .await 563 .unwrap(); 564 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 565 .header("Content-Type", "application/json") 566 .header("Accept", "application/json") 567 .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false})) 568 .send().await.unwrap(); 569 assert_eq!( 570 auth_res.status(), 571 StatusCode::FORBIDDEN, 572 "Deactivated account should be blocked" 573 ); 574 let redirect_uri_a = "https://app-a.com/callback"; 575 let mock_a = setup_mock_client_metadata(redirect_uri_a).await; 576 let client_id_a = mock_a.uri(); 577 let mock_b = setup_mock_client_metadata("https://app-b.com/callback").await; 578 let client_id_b = mock_b.uri(); 579 let suffix2 = &uuid::Uuid::new_v4().simple().to_string()[..8]; 580 let handle2 = format!("cr{}", suffix2); 581 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 582 .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" })) 583 .send().await.unwrap(); 584 let account2: Value = create_res2.json().await.unwrap(); 585 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; 586 let (code_verifier2, code_challenge2) = generate_pkce(); 587 let par_a: Value = http_client 588 .post(format!("{}/oauth/par", url)) 589 .form(&[ 590 ("response_type", "code"), 591 ("client_id", &client_id_a), 592 ("redirect_uri", redirect_uri_a), 593 ("code_challenge", &code_challenge2), 594 ("code_challenge_method", "S256"), 595 ]) 596 .send() 597 .await 598 .unwrap() 599 .json() 600 .await 601 .unwrap(); 602 let request_uri_a = par_a["request_uri"].as_str().unwrap(); 603 let auth_a = http_client.post(format!("{}/oauth/authorize", url)) 604 .header("Content-Type", "application/json") 605 .header("Accept", "application/json") 606 .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false})) 607 .send().await.unwrap(); 608 assert_eq!(auth_a.status(), StatusCode::OK); 609 let auth_body_a: Value = auth_a.json().await.unwrap(); 610 let mut loc_a = auth_body_a["redirect_uri"].as_str().unwrap().to_string(); 611 if loc_a.contains("/oauth/consent") { 612 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url)) 613 .header("Content-Type", "application/json") 614 .json(&json!({"request_uri": request_uri_a, "approved_scopes": ["atproto"], "remember": false})) 615 .send().await.unwrap(); 616 let consent_body: Value = consent_res.json().await.unwrap(); 617 loc_a = consent_body["redirect_uri"].as_str().unwrap().to_string(); 618 } 619 let code_a = loc_a 620 .split("code=") 621 .nth(1) 622 .unwrap() 623 .split('&') 624 .next() 625 .unwrap(); 626 let cross_client = http_client 627 .post(format!("{}/oauth/token", url)) 628 .form(&[ 629 ("grant_type", "authorization_code"), 630 ("code", code_a), 631 ("redirect_uri", redirect_uri_a), 632 ("code_verifier", &code_verifier2), 633 ("client_id", &client_id_b), 634 ]) 635 .send() 636 .await 637 .unwrap(); 638 assert_eq!( 639 cross_client.status(), 640 StatusCode::BAD_REQUEST, 641 "Cross-client code exchange must be rejected" 642 ); 643} 644 645#[tokio::test] 646async fn test_malformed_tokens_and_headers() { 647 let url = base_url().await; 648 let http_client = client(); 649 let malformed = vec![ 650 "", 651 "not-a-token", 652 "one.two", 653 "one.two.three.four", 654 "....", 655 "eyJhbGciOiJIUzI1NiJ9", 656 "eyJhbGciOiJIUzI1NiJ9.", 657 "eyJhbGciOiJIUzI1NiJ9..", 658 ".eyJzdWIiOiJ0ZXN0In0.", 659 "!!invalid!!.eyJ9.sig", 660 ]; 661 for token in &malformed { 662 assert_eq!( 663 http_client 664 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 665 .bearer_auth(token) 666 .send() 667 .await 668 .unwrap() 669 .status(), 670 StatusCode::UNAUTHORIZED 671 ); 672 } 673 let wrong_types = vec!["JWT", "jwt", "at+JWT", ""]; 674 for typ in wrong_types { 675 let header = json!({ "alg": "HS256", "typ": typ }); 676 let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" }); 677 let token = format!( 678 "{}.{}.{}", 679 URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), 680 URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), 681 URL_SAFE_NO_PAD.encode([1u8; 32]) 682 ); 683 assert_eq!( 684 http_client 685 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 686 .bearer_auth(&token) 687 .send() 688 .await 689 .unwrap() 690 .status(), 691 StatusCode::UNAUTHORIZED, 692 "typ='{}' should be rejected", 693 typ 694 ); 695 } 696 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 697 let invalid_formats = vec![ 698 format!("Basic {}", access_token), 699 format!("Digest {}", access_token), 700 access_token.clone(), 701 format!("Bearer{}", access_token), 702 ]; 703 for auth in &invalid_formats { 704 assert_eq!( 705 http_client 706 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 707 .header("Authorization", auth) 708 .send() 709 .await 710 .unwrap() 711 .status(), 712 StatusCode::UNAUTHORIZED 713 ); 714 } 715 assert_eq!( 716 http_client 717 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 718 .send() 719 .await 720 .unwrap() 721 .status(), 722 StatusCode::UNAUTHORIZED 723 ); 724 assert_eq!( 725 http_client 726 .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 727 .header("Authorization", "") 728 .send() 729 .await 730 .unwrap() 731 .status(), 732 StatusCode::UNAUTHORIZED 733 ); 734 let grants = vec![ 735 "client_credentials", 736 "password", 737 "implicit", 738 "", 739 "AUTHORIZATION_CODE", 740 ]; 741 for grant in grants { 742 assert_eq!( 743 http_client 744 .post(format!("{}/oauth/token", url)) 745 .form(&[("grant_type", grant), ("client_id", "https://example.com")]) 746 .send() 747 .await 748 .unwrap() 749 .status(), 750 StatusCode::BAD_REQUEST, 751 "Grant '{}' should be rejected", 752 grant 753 ); 754 } 755} 756 757#[tokio::test] 758async fn test_token_revocation() { 759 let url = base_url().await; 760 let http_client = client(); 761 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await; 762 assert_eq!( 763 http_client 764 .post(format!("{}/oauth/revoke", url)) 765 .form(&[("token", &refresh_token)]) 766 .send() 767 .await 768 .unwrap() 769 .status(), 770 StatusCode::OK 771 ); 772 let introspect: Value = http_client 773 .post(format!("{}/oauth/introspect", url)) 774 .form(&[("token", &access_token)]) 775 .send() 776 .await 777 .unwrap() 778 .json() 779 .await 780 .unwrap(); 781 assert_eq!( 782 introspect["active"], false, 783 "Revoked token should be inactive" 784 ); 785} 786 787fn create_dpop_proof( 788 method: &str, 789 uri: &str, 790 _nonce: Option<&str>, 791 ath: Option<&str>, 792 iat_offset: i64, 793) -> String { 794 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 795 use p256::elliptic_curve::sec1::ToEncodedPoint; 796 let signing_key = SigningKey::random(&mut rand::thread_rng()); 797 let point = signing_key.verifying_key().to_encoded_point(false); 798 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 799 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 800 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 801 let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 802 "htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset }); 803 if let Some(a) = ath { 804 payload["ath"] = json!(a); 805 } 806 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 807 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 808 let signing_input = format!("{}.{}", header_b64, payload_b64); 809 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 810 format!( 811 "{}.{}", 812 signing_input, 813 URL_SAFE_NO_PAD.encode(signature.to_bytes()) 814 ) 815} 816 817#[test] 818fn test_dpop_nonce_security() { 819 let secret1 = b"test-dpop-secret-32-bytes-long!!"; 820 let secret2 = b"different-secret-32-bytes-long!!"; 821 let v1 = DPoPVerifier::new(secret1); 822 let v2 = DPoPVerifier::new(secret2); 823 let nonce = v1.generate_nonce(); 824 assert!(!nonce.is_empty()); 825 assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass"); 826 assert!( 827 v2.validate_nonce(&nonce).is_err(), 828 "Nonce from different secret should fail" 829 ); 830 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); 831 let mut tampered = nonce_bytes.clone(); 832 if !tampered.is_empty() { 833 tampered[0] ^= 0xFF; 834 } 835 assert!( 836 v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered)) 837 .is_err(), 838 "Tampered nonce should fail" 839 ); 840 assert!(v1.validate_nonce("invalid").is_err()); 841 assert!(v1.validate_nonce("").is_err()); 842 assert!(v1.validate_nonce("!!!not-base64!!!").is_err()); 843} 844 845#[test] 846fn test_dpop_proof_validation() { 847 let secret = b"test-dpop-secret-32-bytes-long!!"; 848 let verifier = DPoPVerifier::new(secret); 849 assert!( 850 verifier 851 .verify_proof("not.enough", "POST", "https://example.com", None) 852 .is_err() 853 ); 854 assert!( 855 verifier 856 .verify_proof("invalid", "POST", "https://example.com", None) 857 .is_err() 858 ); 859 let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); 860 assert!( 861 verifier 862 .verify_proof(&proof, "GET", "https://example.com/token", None) 863 .is_err(), 864 "Method mismatch" 865 ); 866 assert!( 867 verifier 868 .verify_proof(&proof, "POST", "https://other.com/token", None) 869 .is_err(), 870 "URI mismatch" 871 ); 872 assert!( 873 verifier 874 .verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None) 875 .is_ok(), 876 "Query params should be ignored" 877 ); 878 let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600); 879 assert!( 880 verifier 881 .verify_proof(&old_proof, "POST", "https://example.com/token", None) 882 .is_err(), 883 "iat too old" 884 ); 885 let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600); 886 assert!( 887 verifier 888 .verify_proof(&future_proof, "POST", "https://example.com/token", None) 889 .is_err(), 890 "iat in future" 891 ); 892 let ath_proof = create_dpop_proof( 893 "GET", 894 "https://example.com/resource", 895 None, 896 Some("wrong"), 897 0, 898 ); 899 assert!( 900 verifier 901 .verify_proof( 902 &ath_proof, 903 "GET", 904 "https://example.com/resource", 905 Some("correct") 906 ) 907 .is_err(), 908 "ath mismatch" 909 ); 910 let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0); 911 assert!( 912 verifier 913 .verify_proof( 914 &no_ath_proof, 915 "GET", 916 "https://example.com/resource", 917 Some("expected") 918 ) 919 .is_err(), 920 "Missing ath" 921 ); 922} 923 924#[test] 925fn test_dpop_proof_signature_attacks() { 926 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 927 use p256::elliptic_curve::sec1::ToEncodedPoint; 928 let secret = b"test-dpop-secret-32-bytes-long!!"; 929 let verifier = DPoPVerifier::new(secret); 930 let signing_key = SigningKey::random(&mut rand::thread_rng()); 931 let attacker_key = SigningKey::random(&mut rand::thread_rng()); 932 let attacker_point = attacker_key.verifying_key().to_encoded_point(false); 933 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap()); 934 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap()); 935 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 936 let payload = json!({ "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 937 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() }); 938 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 939 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 940 let signing_input = format!("{}.{}", header_b64, payload_b64); 941 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 942 let mismatched = format!( 943 "{}.{}", 944 signing_input, 945 URL_SAFE_NO_PAD.encode(signature.to_bytes()) 946 ); 947 assert!( 948 verifier 949 .verify_proof(&mismatched, "POST", "https://example.com/token", None) 950 .is_err(), 951 "Mismatched key should fail" 952 ); 953 let point = signing_key.verifying_key().to_encoded_point(false); 954 let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", 955 "x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } }); 956 let good_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&good_header).unwrap()); 957 let good_input = format!("{}.{}", good_header_b64, payload_b64); 958 let good_sig: Signature = signing_key.sign(good_input.as_bytes()); 959 let mut sig_bytes = good_sig.to_bytes().to_vec(); 960 sig_bytes[0] ^= 0xFF; 961 let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes)); 962 assert!( 963 verifier 964 .verify_proof(&tampered, "POST", "https://example.com/token", None) 965 .is_err(), 966 "Tampered sig should fail" 967 ); 968} 969 970#[test] 971fn test_jwk_thumbprint() { 972 let jwk = DPoPJwk { 973 kty: "EC".to_string(), 974 crv: Some("P-256".to_string()), 975 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()), 976 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()), 977 }; 978 let tp1 = compute_jwk_thumbprint(&jwk).unwrap(); 979 let tp2 = compute_jwk_thumbprint(&jwk).unwrap(); 980 assert_eq!(tp1, tp2, "Thumbprint should be deterministic"); 981 assert!(!tp1.is_empty()); 982 assert!( 983 compute_jwk_thumbprint(&DPoPJwk { 984 kty: "EC".to_string(), 985 crv: Some("secp256k1".to_string()), 986 x: Some("x".to_string()), 987 y: Some("y".to_string()) 988 }) 989 .is_ok() 990 ); 991 assert!( 992 compute_jwk_thumbprint(&DPoPJwk { 993 kty: "OKP".to_string(), 994 crv: Some("Ed25519".to_string()), 995 x: Some("x".to_string()), 996 y: None 997 }) 998 .is_ok() 999 ); 1000 assert!( 1001 compute_jwk_thumbprint(&DPoPJwk { 1002 kty: "EC".to_string(), 1003 crv: None, 1004 x: Some("x".to_string()), 1005 y: Some("y".to_string()) 1006 }) 1007 .is_err() 1008 ); 1009 assert!( 1010 compute_jwk_thumbprint(&DPoPJwk { 1011 kty: "EC".to_string(), 1012 crv: Some("P-256".to_string()), 1013 x: None, 1014 y: Some("y".to_string()) 1015 }) 1016 .is_err() 1017 ); 1018 assert!( 1019 compute_jwk_thumbprint(&DPoPJwk { 1020 kty: "EC".to_string(), 1021 crv: Some("P-256".to_string()), 1022 x: Some("x".to_string()), 1023 y: None 1024 }) 1025 .is_err() 1026 ); 1027 assert!( 1028 compute_jwk_thumbprint(&DPoPJwk { 1029 kty: "RSA".to_string(), 1030 crv: None, 1031 x: None, 1032 y: None 1033 }) 1034 .is_err() 1035 ); 1036} 1037 1038#[test] 1039fn test_dpop_clock_skew() { 1040 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 1041 use p256::elliptic_curve::sec1::ToEncodedPoint; 1042 let secret = b"test-dpop-secret-32-bytes-long!!"; 1043 let verifier = DPoPVerifier::new(secret); 1044 let test_cases = vec![ 1045 (-600, true), 1046 (-301, true), 1047 (-299, false), 1048 (0, false), 1049 (299, false), 1050 (301, true), 1051 (600, true), 1052 ]; 1053 for (offset, should_fail) in test_cases { 1054 let signing_key = SigningKey::random(&mut rand::thread_rng()); 1055 let point = signing_key.verifying_key().to_encoded_point(false); 1056 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 1057 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 1058 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 1059 let payload = json!({ "jti": format!("clock-{}-{}", offset, Utc::now().timestamp_nanos_opt().unwrap_or(0)), 1060 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() + offset }); 1061 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1062 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1063 let signing_input = format!("{}.{}", header_b64, payload_b64); 1064 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 1065 let proof = format!( 1066 "{}.{}", 1067 signing_input, 1068 URL_SAFE_NO_PAD.encode(signature.to_bytes()) 1069 ); 1070 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); 1071 if should_fail { 1072 assert!(result.is_err(), "offset {} should fail", offset); 1073 } else { 1074 assert!(result.is_ok(), "offset {} should pass", offset); 1075 } 1076 } 1077} 1078 1079#[test] 1080fn test_dpop_http_method_case() { 1081 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 1082 use p256::elliptic_curve::sec1::ToEncodedPoint; 1083 let secret = b"test-dpop-secret-32-bytes-long!!"; 1084 let verifier = DPoPVerifier::new(secret); 1085 let signing_key = SigningKey::random(&mut rand::thread_rng()); 1086 let point = signing_key.verifying_key().to_encoded_point(false); 1087 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 1088 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 1089 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 1090 let payload = json!({ "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 1091 "htm": "post", "htu": "https://example.com/token", "iat": Utc::now().timestamp() }); 1092 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 1093 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 1094 let signing_input = format!("{}.{}", header_b64, payload_b64); 1095 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 1096 let proof = format!( 1097 "{}.{}", 1098 signing_input, 1099 URL_SAFE_NO_PAD.encode(signature.to_bytes()) 1100 ); 1101 assert!( 1102 verifier 1103 .verify_proof(&proof, "POST", "https://example.com/token", None) 1104 .is_ok(), 1105 "HTTP method should be case-insensitive" 1106 ); 1107} 1108 1109#[tokio::test] 1110async fn test_delegation_viewer_scope_cannot_write() { 1111 let url = base_url().await; 1112 let http_client = client(); 1113 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 1114 1115 let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 1116 1117 let delegated_handle = format!("dg{}", suffix); 1118 let delegated_res = http_client 1119 .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1120 .bearer_auth(controller_jwt) 1121 .json(&json!({ 1122 "handle": delegated_handle, 1123 "controllerScopes": "" 1124 })) 1125 .send() 1126 .await 1127 .unwrap(); 1128 if delegated_res.status() != StatusCode::OK { 1129 let error_body = delegated_res.text().await.unwrap(); 1130 panic!("Failed to create delegated account: {}", error_body); 1131 } 1132 let delegated_account: Value = delegated_res.json().await.unwrap(); 1133 let delegated_did = delegated_account["did"].as_str().unwrap(); 1134 1135 let redirect_uri = "https://example.com/deleg-callback"; 1136 let mock_client = setup_mock_client_metadata(redirect_uri).await; 1137 let client_id = mock_client.uri(); 1138 let (code_verifier, code_challenge) = generate_pkce(); 1139 1140 let par_body: Value = http_client 1141 .post(format!("{}/oauth/par", url)) 1142 .form(&[ 1143 ("response_type", "code"), 1144 ("client_id", &client_id), 1145 ("redirect_uri", redirect_uri), 1146 ("code_challenge", &code_challenge), 1147 ("code_challenge_method", "S256"), 1148 ("scope", "atproto"), 1149 ("login_hint", delegated_did), 1150 ]) 1151 .send() 1152 .await 1153 .unwrap() 1154 .json() 1155 .await 1156 .unwrap(); 1157 let request_uri = par_body["request_uri"].as_str().unwrap(); 1158 1159 let auth_res = http_client 1160 .post(format!("{}/oauth/delegation/auth", url)) 1161 .header("Content-Type", "application/json") 1162 .json(&json!({ 1163 "request_uri": request_uri, 1164 "delegated_did": delegated_did, 1165 "controller_did": controller_did, 1166 "password": "Testpass123!", 1167 "remember_device": false 1168 })) 1169 .send() 1170 .await 1171 .unwrap(); 1172 if auth_res.status() != StatusCode::OK { 1173 let error_body = auth_res.text().await.unwrap(); 1174 panic!("Delegation auth failed: {}", error_body); 1175 } 1176 let auth_body: Value = auth_res.json().await.unwrap(); 1177 assert!( 1178 auth_body["success"].as_bool().unwrap_or(false), 1179 "Delegation auth should succeed: {:?}", 1180 auth_body 1181 ); 1182 1183 let consent_res = http_client 1184 .post(format!("{}/oauth/authorize/consent", url)) 1185 .header("Content-Type", "application/json") 1186 .json(&json!({ 1187 "request_uri": request_uri, 1188 "approved_scopes": ["atproto"], 1189 "remember": false 1190 })) 1191 .send() 1192 .await 1193 .unwrap(); 1194 if consent_res.status() != StatusCode::OK { 1195 let error_body = consent_res.text().await.unwrap(); 1196 panic!("Consent failed: {}", error_body); 1197 } 1198 let consent_body: Value = consent_res.json().await.unwrap(); 1199 let location = consent_body["redirect_uri"].as_str().unwrap(); 1200 1201 let code = location 1202 .split("code=") 1203 .nth(1) 1204 .unwrap() 1205 .split('&') 1206 .next() 1207 .unwrap(); 1208 1209 let token_res = http_client 1210 .post(format!("{}/oauth/token", url)) 1211 .form(&[ 1212 ("grant_type", "authorization_code"), 1213 ("code", code), 1214 ("redirect_uri", redirect_uri), 1215 ("code_verifier", &code_verifier), 1216 ("client_id", &client_id), 1217 ]) 1218 .send() 1219 .await 1220 .unwrap(); 1221 assert_eq!(token_res.status(), StatusCode::OK); 1222 let tokens: Value = token_res.json().await.unwrap(); 1223 let access_token = tokens["access_token"].as_str().unwrap(); 1224 1225 let create_post_res = http_client 1226 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1227 .bearer_auth(access_token) 1228 .json(&json!({ 1229 "repo": delegated_did, 1230 "collection": "app.bsky.feed.post", 1231 "record": { 1232 "$type": "app.bsky.feed.post", 1233 "text": "Test post from viewer", 1234 "createdAt": Utc::now().to_rfc3339() 1235 } 1236 })) 1237 .send() 1238 .await 1239 .unwrap(); 1240 1241 assert_eq!( 1242 create_post_res.status(), 1243 StatusCode::FORBIDDEN, 1244 "Viewer scope delegation should not be able to create posts" 1245 ); 1246 let error_body: Value = create_post_res.json().await.unwrap(); 1247 assert_eq!( 1248 error_body["error"].as_str().unwrap(), 1249 "InsufficientScope", 1250 "Error should be InsufficientScope" 1251 ); 1252}