An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): implement discover_pds and discover_auth_server

- discover_pds fetches DID document from plc.directory
- Extracts PDS endpoint from services.atproto_pds
- Verifies PDS reachability with 5-second timeout
- Returns PDS_UNREACHABLE if endpoint is unreachable
- Returns DID_NOT_FOUND on 404 from plc.directory
- discover_auth_server fetches OAuth authorization server metadata
- Validates response_types_supported includes 'code'
- Validates code_challenge_methods_supported includes 'S256'
- Returns PDS_UNREACHABLE on HTTP errors or connection failures

Verifies AC3.4, AC3.5, AC3.7, AC3.8

authored by

Malpercio and committed by
Tangled
f9c57a8b 8b93837a

+417
+417
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 5 5 // Returns: PDS endpoints, authorization server metadata, or error codes 6 6 7 7 use std::collections::HashMap; 8 + use std::time::Duration; 8 9 9 10 use reqwest::Client; 10 11 use serde::{Deserialize, Serialize}; ··· 197 198 // Neither DNS nor HTTP succeeded 198 199 Err(PdsClientError::HandleNotFound) 199 200 } 201 + 202 + /// Fetch the DID document from plc.directory and extract the PDS endpoint. 203 + /// 204 + /// Verifies: 205 + /// - AC3.4: PDS endpoint extracted from DID document 206 + /// - AC3.7: Returns `DID_NOT_FOUND` on 404 207 + /// - AC3.8: Returns `PDS_UNREACHABLE` when PDS endpoint is down 208 + pub async fn discover_pds( 209 + &self, 210 + did: &str, 211 + ) -> Result<(String, PlcDidDocument), PdsClientError> { 212 + let url = format!("{}/{}", self.plc_directory_url, did); 213 + 214 + // Fetch the DID document from plc.directory 215 + let response = 216 + self.client 217 + .get(&url) 218 + .send() 219 + .await 220 + .map_err(|e| PdsClientError::NetworkError { 221 + message: format!("failed to fetch DID document: {}", e), 222 + })?; 223 + 224 + match response.status() { 225 + s if s == 404 => return Err(PdsClientError::DidNotFound), 226 + s if !s.is_success() => { 227 + return Err(PdsClientError::NetworkError { 228 + message: format!("plc.directory returned {}", s), 229 + }); 230 + } 231 + _ => {} 232 + } 233 + 234 + // Parse response as PlcDidDocument 235 + let doc: PlcDidDocument = 236 + response 237 + .json() 238 + .await 239 + .map_err(|e| PdsClientError::InvalidResponse { 240 + message: format!("failed to parse DID document: {}", e), 241 + })?; 242 + 243 + // Extract the atproto_pds service 244 + let pds_service = 245 + doc.services 246 + .get("atproto_pds") 247 + .ok_or_else(|| PdsClientError::InvalidResponse { 248 + message: "missing atproto_pds service".to_string(), 249 + })?; 250 + 251 + let pds_endpoint = &pds_service.endpoint; 252 + 253 + // Verify PDS reachability with a HEAD request (5-second timeout) 254 + let timeout_client = Client::builder() 255 + .timeout(Duration::from_secs(5)) 256 + .build() 257 + .map_err(|e| PdsClientError::PdsUnreachable { 258 + source: format!("failed to create HTTP client: {}", e), 259 + })?; 260 + 261 + timeout_client 262 + .head(pds_endpoint) 263 + .send() 264 + .await 265 + .map_err(|e| PdsClientError::PdsUnreachable { 266 + source: format!("failed to reach PDS endpoint: {}", e), 267 + })?; 268 + 269 + Ok((pds_endpoint.to_string(), doc)) 270 + } 271 + 272 + /// Fetch OAuth authorization server metadata from the PDS. 273 + /// 274 + /// Verifies: 275 + /// - AC3.5: Auth server metadata is fetched and parsed correctly 276 + /// - Validates that `response_types_supported` includes "code" 277 + /// - Validates that `code_challenge_methods_supported` includes "S256" 278 + pub async fn discover_auth_server( 279 + &self, 280 + pds_url: &str, 281 + ) -> Result<AuthServerMetadata, PdsClientError> { 282 + let url = format!("{}/.well-known/oauth-authorization-server", pds_url); 283 + 284 + let response = 285 + self.client 286 + .get(&url) 287 + .send() 288 + .await 289 + .map_err(|e| PdsClientError::PdsUnreachable { 290 + source: format!("failed to fetch OAuth metadata: {}", e), 291 + })?; 292 + 293 + if !response.status().is_success() { 294 + return Err(PdsClientError::PdsUnreachable { 295 + source: format!( 296 + "OAuth metadata fetch returned {} from {}", 297 + response.status(), 298 + pds_url 299 + ), 300 + }); 301 + } 302 + 303 + let metadata: AuthServerMetadata = 304 + response 305 + .json() 306 + .await 307 + .map_err(|e| PdsClientError::InvalidResponse { 308 + message: format!("failed to parse OAuth metadata: {}", e), 309 + })?; 310 + 311 + // Validate required capabilities 312 + if !metadata 313 + .response_types_supported 314 + .contains(&"code".to_string()) 315 + { 316 + return Err(PdsClientError::InvalidResponse { 317 + message: "OAuth metadata missing 'code' in response_types_supported".to_string(), 318 + }); 319 + } 320 + 321 + if !metadata 322 + .code_challenge_methods_supported 323 + .contains(&"S256".to_string()) 324 + { 325 + return Err(PdsClientError::InvalidResponse { 326 + message: "OAuth metadata missing 'S256' in code_challenge_methods_supported" 327 + .to_string(), 328 + }); 329 + } 330 + 331 + Ok(metadata) 332 + } 200 333 } 201 334 202 335 impl Default for PdsClient { ··· 287 420 #[cfg(test)] 288 421 mod tests { 289 422 use super::*; 423 + use httpmock::prelude::*; 290 424 291 425 #[test] 292 426 fn test_pds_client_default() { ··· 309 443 assert!(!json.contains("rotationKeys")); 310 444 assert!(!json.contains("alsoKnownAs")); 311 445 assert!(json.contains("token")); 446 + } 447 + 448 + // ============================================================================ 449 + // TASK 4 & 5: discover_pds and discover_auth_server tests 450 + // ============================================================================ 451 + 452 + /// AC3.4: PDS endpoint extracted from DID doc 453 + #[tokio::test] 454 + async fn test_discover_pds_extracts_endpoint() { 455 + let mock_server = MockServer::start(); 456 + let pds_endpoint = format!("{}/pds", mock_server.base_url()); 457 + 458 + let did_doc_json = serde_json::json!({ 459 + "did": "did:plc:test123", 460 + "alsoKnownAs": ["at://alice.example.com"], 461 + "rotationKeys": ["did:key:zQ3test1", "did:key:zQ3test2"], 462 + "verificationMethods": {"atproto": "did:key:zQ3test1"}, 463 + "services": { 464 + "atproto_pds": { 465 + "type": "AtprotoPersonalDataServer", 466 + "endpoint": pds_endpoint 467 + } 468 + } 469 + }); 470 + 471 + // Mock the plc.directory GET request 472 + mock_server.mock(|when, then| { 473 + when.method(GET).path("/did:plc:test123"); 474 + then.status(200) 475 + .header("content-type", "application/json") 476 + .json_body(did_doc_json); 477 + }); 478 + 479 + // Mock the PDS reachability check 480 + mock_server.mock(|when, then| { 481 + when.method(GET).path("/pds"); 482 + then.status(200); 483 + }); 484 + 485 + let client = PdsClient::new_for_test(mock_server.base_url()); 486 + let result = client.discover_pds("did:plc:test123").await; 487 + 488 + assert!(result.is_ok()); 489 + let (pds_url, doc) = result.unwrap(); 490 + assert!(pds_url.contains("/pds")); 491 + assert_eq!(doc.did, "did:plc:test123"); 492 + assert_eq!(doc.also_known_as, vec!["at://alice.example.com"]); 493 + assert_eq!(doc.rotation_keys.len(), 2); 494 + } 495 + 496 + /// AC3.7: DID_NOT_FOUND when plc.directory returns 404 497 + #[tokio::test] 498 + async fn test_discover_pds_did_not_found() { 499 + let mock_server = MockServer::start(); 500 + 501 + mock_server.mock(|when, then| { 502 + when.method(GET).path("/did:plc:nonexistent"); 503 + then.status(404); 504 + }); 505 + 506 + let client = PdsClient::new_for_test(mock_server.base_url()); 507 + let result = client.discover_pds("did:plc:nonexistent").await; 508 + 509 + assert!(result.is_err()); 510 + match result.unwrap_err() { 511 + PdsClientError::DidNotFound => { 512 + // Expected 513 + } 514 + e => panic!("Expected DidNotFound, got: {:?}", e), 515 + } 516 + } 517 + 518 + /// AC3.8: PDS_UNREACHABLE when PDS endpoint is down 519 + #[tokio::test] 520 + async fn test_discover_pds_pds_unreachable() { 521 + let mock_server = MockServer::start(); 522 + 523 + let did_doc_json = serde_json::json!({ 524 + "did": "did:plc:test123", 525 + "alsoKnownAs": [], 526 + "rotationKeys": [], 527 + "verificationMethods": {}, 528 + "services": { 529 + "atproto_pds": { 530 + "type": "AtprotoPersonalDataServer", 531 + "endpoint": "http://127.0.0.1:1" 532 + } 533 + } 534 + }); 535 + 536 + mock_server.mock(|when, then| { 537 + when.method(GET).path("/did:plc:test123"); 538 + then.status(200) 539 + .header("content-type", "application/json") 540 + .json_body(did_doc_json); 541 + }); 542 + 543 + let client = PdsClient::new_for_test(mock_server.base_url()); 544 + let result = client.discover_pds("did:plc:test123").await; 545 + 546 + assert!(result.is_err()); 547 + match result.unwrap_err() { 548 + PdsClientError::PdsUnreachable { .. } => { 549 + // Expected 550 + } 551 + e => panic!("Expected PdsUnreachable, got: {:?}", e), 552 + } 553 + } 554 + 555 + /// AC3.8: InvalidResponse when atproto_pds service is missing 556 + #[tokio::test] 557 + async fn test_discover_pds_missing_service() { 558 + let mock_server = MockServer::start(); 559 + 560 + let did_doc_json = serde_json::json!({ 561 + "did": "did:plc:test123", 562 + "alsoKnownAs": [], 563 + "rotationKeys": [], 564 + "verificationMethods": {}, 565 + "services": {} 566 + }); 567 + 568 + mock_server.mock(|when, then| { 569 + when.method(GET).path("/did:plc:test123"); 570 + then.status(200) 571 + .header("content-type", "application/json") 572 + .json_body(did_doc_json); 573 + }); 574 + 575 + let client = PdsClient::new_for_test(mock_server.base_url()); 576 + let result = client.discover_pds("did:plc:test123").await; 577 + 578 + assert!(result.is_err()); 579 + match result.unwrap_err() { 580 + PdsClientError::InvalidResponse { .. } => { 581 + // Expected 582 + } 583 + e => panic!("Expected InvalidResponse, got: {:?}", e), 584 + } 585 + } 586 + 587 + /// AC3.5: Auth server metadata is fetched and validated 588 + #[tokio::test] 589 + async fn test_discover_auth_server_success() { 590 + let mock_server = MockServer::start(); 591 + 592 + let metadata_json = serde_json::json!({ 593 + "issuer": "https://pds.example.com", 594 + "authorization_endpoint": "https://pds.example.com/oauth/authorize", 595 + "token_endpoint": "https://pds.example.com/oauth/token", 596 + "pushed_authorization_request_endpoint": "https://pds.example.com/oauth/par", 597 + "response_types_supported": ["code"], 598 + "grant_types_supported": ["authorization_code", "refresh_token"], 599 + "code_challenge_methods_supported": ["S256"], 600 + "dpop_signing_alg_values_supported": ["ES256"], 601 + "scopes_supported": ["atproto", "transition:generic"] 602 + }); 603 + 604 + mock_server.mock(|when, then| { 605 + when.method(GET) 606 + .path("/.well-known/oauth-authorization-server"); 607 + then.status(200) 608 + .header("content-type", "application/json") 609 + .json_body(metadata_json); 610 + }); 611 + 612 + let client = PdsClient::new(); 613 + let result = client.discover_auth_server(&mock_server.base_url()).await; 614 + 615 + assert!(result.is_ok()); 616 + let metadata = result.unwrap(); 617 + assert_eq!(metadata.issuer, "https://pds.example.com"); 618 + assert_eq!( 619 + metadata.authorization_endpoint, 620 + "https://pds.example.com/oauth/authorize" 621 + ); 622 + assert_eq!( 623 + metadata.token_endpoint, 624 + "https://pds.example.com/oauth/token" 625 + ); 626 + assert!(metadata 627 + .response_types_supported 628 + .contains(&"code".to_string())); 629 + assert!(metadata 630 + .code_challenge_methods_supported 631 + .contains(&"S256".to_string())); 632 + } 633 + 634 + /// discover_auth_server rejects missing S256 635 + #[tokio::test] 636 + async fn test_discover_auth_server_missing_s256() { 637 + let mock_server = MockServer::start(); 638 + 639 + let metadata_json = serde_json::json!({ 640 + "issuer": "https://pds.example.com", 641 + "authorization_endpoint": "https://pds.example.com/oauth/authorize", 642 + "token_endpoint": "https://pds.example.com/oauth/token", 643 + "pushed_authorization_request_endpoint": "https://pds.example.com/oauth/par", 644 + "response_types_supported": ["code"], 645 + "grant_types_supported": ["authorization_code"], 646 + "code_challenge_methods_supported": ["plain"], 647 + "dpop_signing_alg_values_supported": ["ES256"], 648 + "scopes_supported": ["atproto"] 649 + }); 650 + 651 + mock_server.mock(|when, then| { 652 + when.method(GET) 653 + .path("/.well-known/oauth-authorization-server"); 654 + then.status(200) 655 + .header("content-type", "application/json") 656 + .json_body(metadata_json); 657 + }); 658 + 659 + let client = PdsClient::new(); 660 + let result = client.discover_auth_server(&mock_server.base_url()).await; 661 + 662 + assert!(result.is_err()); 663 + match result.unwrap_err() { 664 + PdsClientError::InvalidResponse { message } => { 665 + assert!(message.contains("S256")); 666 + } 667 + e => panic!("Expected InvalidResponse, got: {:?}", e), 668 + } 669 + } 670 + 671 + /// discover_auth_server rejects missing "code" response type 672 + #[tokio::test] 673 + async fn test_discover_auth_server_missing_code_response_type() { 674 + let mock_server = MockServer::start(); 675 + 676 + let metadata_json = serde_json::json!({ 677 + "issuer": "https://pds.example.com", 678 + "authorization_endpoint": "https://pds.example.com/oauth/authorize", 679 + "token_endpoint": "https://pds.example.com/oauth/token", 680 + "pushed_authorization_request_endpoint": "https://pds.example.com/oauth/par", 681 + "response_types_supported": ["id_token"], 682 + "grant_types_supported": ["authorization_code"], 683 + "code_challenge_methods_supported": ["S256"], 684 + "dpop_signing_alg_values_supported": ["ES256"], 685 + "scopes_supported": ["atproto"] 686 + }); 687 + 688 + mock_server.mock(|when, then| { 689 + when.method(GET) 690 + .path("/.well-known/oauth-authorization-server"); 691 + then.status(200) 692 + .header("content-type", "application/json") 693 + .json_body(metadata_json); 694 + }); 695 + 696 + let client = PdsClient::new(); 697 + let result = client.discover_auth_server(&mock_server.base_url()).await; 698 + 699 + assert!(result.is_err()); 700 + match result.unwrap_err() { 701 + PdsClientError::InvalidResponse { message } => { 702 + assert!(message.contains("code")); 703 + } 704 + e => panic!("Expected InvalidResponse, got: {:?}", e), 705 + } 706 + } 707 + 708 + /// discover_auth_server returns PdsUnreachable on HTTP error 709 + #[tokio::test] 710 + async fn test_discover_auth_server_pds_unreachable() { 711 + let mock_server = MockServer::start(); 712 + 713 + mock_server.mock(|when, then| { 714 + when.method(GET) 715 + .path("/.well-known/oauth-authorization-server"); 716 + then.status(500); 717 + }); 718 + 719 + let client = PdsClient::new(); 720 + let result = client.discover_auth_server(&mock_server.base_url()).await; 721 + 722 + assert!(result.is_err()); 723 + match result.unwrap_err() { 724 + PdsClientError::PdsUnreachable { .. } => { 725 + // Expected 726 + } 727 + e => panic!("Expected PdsUnreachable, got: {:?}", e), 728 + } 312 729 } 313 730 314 731 // ============================================================================