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 PDS OAuth PAR and token exchange helpers plus XRPC identity methods

Implements Tasks 6 and 7 from Subcomponent C of phase_03:

Task 6: PDS OAuth helpers (PAR + token exchange)
- pds_par(): Push Authorization Request to arbitrary PDS with PKCE + DPoP
- pds_token_exchange(): Exchange authorization code for tokens at PDS endpoint
- build_pds_authorize_url(): Construct OAuth authorization browser redirect URL

Task 7: Tests for PDS OAuth helpers and XRPC identity methods
- Tests for PAR request construction and error handling
- Tests for token exchange with raw response return
- Tests for build_pds_authorize_url URL formatting

Added XRPC identity methods:
- request_plc_operation_signature(): Trigger email verification on PDS
- sign_plc_operation(): Send email token and rotation keys to get signed operation
- get_recommended_did_credentials(): Fetch PDS recommended credentials for DID

All methods follow the existing patterns from oauth_client.rs and http.rs.
Added urlencoding = "2" dependency for URL parameter encoding.

authored by

Malpercio and committed by
Tangled
ee391508 f9c57a8b

+731
+1
apps/identity-wallet/src-tauri/Cargo.toml
··· 37 37 tokio = { workspace = true } 38 38 hickory-resolver = { workspace = true } 39 39 zeroize = { workspace = true } 40 + urlencoding = "2" 40 41 41 42 [dev-dependencies] 42 43 tokio = { version = "1", features = ["macros", "rt"] }
+730
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 330 330 331 331 Ok(metadata) 332 332 } 333 + 334 + /// Perform a Pushed Authorization Request to an arbitrary PDS. 335 + /// 336 + /// Verifies: AC3.6 (PAR sends correct request with PKCE, DPoP, and optional login_hint) 337 + pub async fn pds_par( 338 + &self, 339 + metadata: &AuthServerMetadata, 340 + pkce_challenge: &str, 341 + state_param: &str, 342 + dpop_proof: &str, 343 + dpop_jkt: &str, 344 + login_hint: Option<&str>, 345 + ) -> Result<PdsParResponse, PdsClientError> { 346 + let par_url = metadata 347 + .pushed_authorization_request_endpoint 348 + .clone() 349 + .unwrap_or_else(|| format!("{}/oauth/par", metadata.issuer)); 350 + 351 + let mut form_data = vec![ 352 + ("response_type", "code".to_string()), 353 + ("code_challenge_method", "S256".to_string()), 354 + ("code_challenge", pkce_challenge.to_string()), 355 + ("state", state_param.to_string()), 356 + ("client_id", "dev.malpercio.identitywallet".to_string()), 357 + ( 358 + "redirect_uri", 359 + "dev.malpercio.identitywallet:/oauth/callback".to_string(), 360 + ), 361 + ("scope", "atproto transition:generic".to_string()), 362 + ("dpop_jkt", dpop_jkt.to_string()), 363 + ]; 364 + 365 + if let Some(hint) = login_hint { 366 + form_data.push(("login_hint", hint.to_string())); 367 + } 368 + 369 + let response = self 370 + .client 371 + .post(&par_url) 372 + .header("DPoP", dpop_proof) 373 + .header("Content-Type", "application/x-www-form-urlencoded") 374 + .form(&form_data) 375 + .send() 376 + .await 377 + .map_err(|e| PdsClientError::OAuthFailed { 378 + message: format!("PAR request failed: {}", e), 379 + })?; 380 + 381 + let status = response.status(); 382 + if !status.is_success() { 383 + let error_body = response.text().await.unwrap_or_default(); 384 + return Err(PdsClientError::OAuthFailed { 385 + message: format!("PAR returned {}: {}", status, error_body), 386 + }); 387 + } 388 + 389 + let json_resp = 390 + response 391 + .json::<PdsParResponse>() 392 + .await 393 + .map_err(|e| PdsClientError::OAuthFailed { 394 + message: format!("failed to parse PAR response: {}", e), 395 + })?; 396 + 397 + Ok(json_resp) 398 + } 399 + 400 + /// Exchange authorization code for tokens at an arbitrary PDS. 401 + /// 402 + /// Verifies: AC3.6 (token exchange sends correct request with code, verifier, and DPoP) 403 + /// 404 + /// Returns the raw response so the caller can handle nonce retry logic. 405 + /// Only transport-level failures are mapped to PdsClientError; HTTP error statuses 406 + /// are returned as-is for the caller to inspect. 407 + pub async fn pds_token_exchange( 408 + &self, 409 + metadata: &AuthServerMetadata, 410 + code: &str, 411 + pkce_verifier: &str, 412 + dpop_proof: &str, 413 + ) -> Result<reqwest::Response, PdsClientError> { 414 + let token_url = &metadata.token_endpoint; 415 + 416 + let form_data = vec![ 417 + ("grant_type", "authorization_code"), 418 + ("code", code), 419 + ( 420 + "redirect_uri", 421 + "dev.malpercio.identitywallet:/oauth/callback", 422 + ), 423 + ("code_verifier", pkce_verifier), 424 + ("client_id", "dev.malpercio.identitywallet"), 425 + ]; 426 + 427 + self.client 428 + .post(token_url) 429 + .header("DPoP", dpop_proof) 430 + .header("Content-Type", "application/x-www-form-urlencoded") 431 + .form(&form_data) 432 + .send() 433 + .await 434 + .map_err(|e| PdsClientError::OAuthFailed { 435 + message: format!("token exchange request failed: {}", e), 436 + }) 437 + } 438 + 439 + /// Build the browser redirect URL for OAuth authorization. 440 + /// 441 + /// Constructs `{authorization_endpoint}?client_id=...&request_uri=...` with optional login_hint. 442 + pub fn build_pds_authorize_url( 443 + metadata: &AuthServerMetadata, 444 + request_uri: &str, 445 + login_hint: Option<&str>, 446 + ) -> String { 447 + let mut url = format!( 448 + "{}?client_id=dev.malpercio.identitywallet&request_uri={}", 449 + metadata.authorization_endpoint, 450 + urlencoding::encode(request_uri) 451 + ); 452 + 453 + if let Some(hint) = login_hint { 454 + url.push_str(&format!("&login_hint={}", urlencoding::encode(hint))); 455 + } 456 + 457 + url 458 + } 333 459 } 334 460 335 461 impl Default for PdsClient { ··· 417 543 } 418 544 } 419 545 546 + // ============================================================================ 547 + // XRPC Identity methods (require DPoP-authenticated OAuthClient) 548 + // ============================================================================ 549 + 550 + /// Request a PLC operation signature from the PDS. 551 + /// 552 + /// Triggers email verification on the PDS. 553 + pub async fn request_plc_operation_signature( 554 + client: &crate::oauth_client::OAuthClient, 555 + ) -> Result<(), PdsClientError> { 556 + let resp = client 557 + .post( 558 + "/xrpc/com.atproto.identity.requestPlcOperationSignature", 559 + &serde_json::json!({}), 560 + ) 561 + .await 562 + .map_err(|_| PdsClientError::NetworkError { 563 + message: "request_plc_operation_signature failed".to_string(), 564 + })?; 565 + 566 + if resp.status().is_success() { 567 + Ok(()) 568 + } else { 569 + Err(PdsClientError::NetworkError { 570 + message: format!("request_plc_operation_signature returned {}", resp.status()), 571 + }) 572 + } 573 + } 574 + 575 + /// Sign a PLC operation with credentials from the PDS. 576 + pub async fn sign_plc_operation( 577 + client: &crate::oauth_client::OAuthClient, 578 + request: &SignPlcOperationRequest, 579 + ) -> Result<SignPlcOperationResponse, PdsClientError> { 580 + let resp = client 581 + .post("/xrpc/com.atproto.identity.signPlcOperation", request) 582 + .await 583 + .map_err(|_| PdsClientError::NetworkError { 584 + message: "sign_plc_operation failed".to_string(), 585 + })?; 586 + 587 + if !resp.status().is_success() { 588 + return Err(PdsClientError::NetworkError { 589 + message: format!("sign_plc_operation returned {}", resp.status()), 590 + }); 591 + } 592 + 593 + resp.json::<SignPlcOperationResponse>() 594 + .await 595 + .map_err(|e| PdsClientError::NetworkError { 596 + message: format!("failed to parse sign_plc_operation response: {}", e), 597 + }) 598 + } 599 + 600 + /// Fetch recommended credentials for the DID from the PDS. 601 + pub async fn get_recommended_did_credentials( 602 + client: &crate::oauth_client::OAuthClient, 603 + ) -> Result<RecommendedCredentials, PdsClientError> { 604 + let resp = client 605 + .get("/xrpc/com.atproto.identity.getRecommendedDidCredentials") 606 + .await 607 + .map_err(|_| PdsClientError::NetworkError { 608 + message: "get_recommended_did_credentials failed".to_string(), 609 + })?; 610 + 611 + if !resp.status().is_success() { 612 + return Err(PdsClientError::NetworkError { 613 + message: format!("get_recommended_did_credentials returned {}", resp.status()), 614 + }); 615 + } 616 + 617 + resp.json::<RecommendedCredentials>() 618 + .await 619 + .map_err(|e| PdsClientError::NetworkError { 620 + message: format!( 621 + "failed to parse get_recommended_did_credentials response: {}", 622 + e 623 + ), 624 + }) 625 + } 626 + 420 627 #[cfg(test)] 421 628 mod tests { 422 629 use super::*; ··· 809 1016 eprintln!("Got different error (may be expected in sandbox): {}", e); 810 1017 } 811 1018 } 1019 + } 1020 + 1021 + // ============================================================================ 1022 + // TASK 6 & 7: PAR and token exchange tests 1023 + // ============================================================================ 1024 + 1025 + /// AC3.6: PAR sends correct request with PKCE, DPoP, and optional login_hint 1026 + #[tokio::test] 1027 + async fn test_pds_par_sends_correct_request() { 1028 + let mock_server = MockServer::start(); 1029 + 1030 + let mock_par = mock_server.mock(|when, then| { 1031 + when.method(httpmock::Method::POST) 1032 + .path("/oauth/par") 1033 + .header_exists("DPoP") 1034 + .header_exists("Content-Type"); 1035 + then.status(200).json_body(serde_json::json!({ 1036 + "request_uri": "urn:ietf:params:oauth:request_uri:test", 1037 + "expires_in": 60 1038 + })); 1039 + }); 1040 + 1041 + let metadata = AuthServerMetadata { 1042 + issuer: mock_server.base_url(), 1043 + authorization_endpoint: format!("{}/oauth/authorize", mock_server.base_url()), 1044 + token_endpoint: format!("{}/oauth/token", mock_server.base_url()), 1045 + pushed_authorization_request_endpoint: Some(format!( 1046 + "{}/oauth/par", 1047 + mock_server.base_url() 1048 + )), 1049 + response_types_supported: vec!["code".to_string()], 1050 + grant_types_supported: vec!["authorization_code".to_string()], 1051 + code_challenge_methods_supported: vec!["S256".to_string()], 1052 + dpop_signing_alg_values_supported: Some(vec!["ES256".to_string()]), 1053 + scopes_supported: Some(vec!["atproto".to_string()]), 1054 + }; 1055 + 1056 + let client = PdsClient::new(); 1057 + let result = client 1058 + .pds_par( 1059 + &metadata, 1060 + "test_pkce_challenge", 1061 + "test_state", 1062 + "test_dpop_proof", 1063 + "test_dpop_jkt", 1064 + Some("user@example.com"), 1065 + ) 1066 + .await; 1067 + 1068 + assert!(result.is_ok()); 1069 + let par_response = result.unwrap(); 1070 + assert_eq!( 1071 + par_response.request_uri, 1072 + "urn:ietf:params:oauth:request_uri:test" 1073 + ); 1074 + assert_eq!(par_response.expires_in, 60); 1075 + assert_eq!(mock_par.hits(), 1); 1076 + } 1077 + 1078 + /// AC3.6: PAR without login_hint 1079 + #[tokio::test] 1080 + async fn test_pds_par_without_login_hint() { 1081 + let mock_server = MockServer::start(); 1082 + 1083 + mock_server.mock(|when, then| { 1084 + when.method(httpmock::Method::POST).path("/oauth/par"); 1085 + then.status(200).json_body(serde_json::json!({ 1086 + "request_uri": "urn:ietf:params:oauth:request_uri:test2", 1087 + "expires_in": 120 1088 + })); 1089 + }); 1090 + 1091 + let metadata = AuthServerMetadata { 1092 + issuer: mock_server.base_url(), 1093 + authorization_endpoint: format!("{}/oauth/authorize", mock_server.base_url()), 1094 + token_endpoint: format!("{}/oauth/token", mock_server.base_url()), 1095 + pushed_authorization_request_endpoint: Some(format!( 1096 + "{}/oauth/par", 1097 + mock_server.base_url() 1098 + )), 1099 + response_types_supported: vec!["code".to_string()], 1100 + grant_types_supported: vec!["authorization_code".to_string()], 1101 + code_challenge_methods_supported: vec!["S256".to_string()], 1102 + dpop_signing_alg_values_supported: None, 1103 + scopes_supported: None, 1104 + }; 1105 + 1106 + let client = PdsClient::new(); 1107 + let result = client 1108 + .pds_par(&metadata, "challenge", "state", "proof", "jkt", None) 1109 + .await; 1110 + 1111 + assert!(result.is_ok()); 1112 + } 1113 + 1114 + /// AC3.6: PAR failure returns OAuthFailed 1115 + #[tokio::test] 1116 + async fn test_pds_par_failure() { 1117 + let mock_server = MockServer::start(); 1118 + 1119 + mock_server.mock(|when, then| { 1120 + when.method(httpmock::Method::POST).path("/oauth/par"); 1121 + then.status(400).json_body(serde_json::json!({ 1122 + "error": "invalid_request", 1123 + "error_description": "missing code_challenge" 1124 + })); 1125 + }); 1126 + 1127 + let metadata = AuthServerMetadata { 1128 + issuer: mock_server.base_url(), 1129 + authorization_endpoint: format!("{}/oauth/authorize", mock_server.base_url()), 1130 + token_endpoint: format!("{}/oauth/token", mock_server.base_url()), 1131 + pushed_authorization_request_endpoint: Some(format!( 1132 + "{}/oauth/par", 1133 + mock_server.base_url() 1134 + )), 1135 + response_types_supported: vec!["code".to_string()], 1136 + grant_types_supported: vec!["authorization_code".to_string()], 1137 + code_challenge_methods_supported: vec!["S256".to_string()], 1138 + dpop_signing_alg_values_supported: None, 1139 + scopes_supported: None, 1140 + }; 1141 + 1142 + let client = PdsClient::new(); 1143 + let result = client 1144 + .pds_par(&metadata, "challenge", "state", "proof", "jkt", None) 1145 + .await; 1146 + 1147 + assert!(result.is_err()); 1148 + match result.unwrap_err() { 1149 + PdsClientError::OAuthFailed { .. } => { 1150 + // Expected 1151 + } 1152 + e => panic!("Expected OAuthFailed, got: {:?}", e), 1153 + } 1154 + } 1155 + 1156 + /// AC3.6: Token exchange sends correct request 1157 + #[tokio::test] 1158 + async fn test_pds_token_exchange_sends_correct_request() { 1159 + let mock_server = MockServer::start(); 1160 + 1161 + mock_server.mock(|when, then| { 1162 + when.method(httpmock::Method::POST) 1163 + .path("/oauth/token") 1164 + .header_exists("DPoP") 1165 + .header_exists("Content-Type"); 1166 + then.status(200).json_body(serde_json::json!({ 1167 + "access_token": "test_access_token", 1168 + "token_type": "DPoP", 1169 + "expires_in": 300, 1170 + "refresh_token": "test_refresh_token", 1171 + "scope": "atproto transition:generic" 1172 + })); 1173 + }); 1174 + 1175 + let metadata = AuthServerMetadata { 1176 + issuer: mock_server.base_url(), 1177 + authorization_endpoint: format!("{}/oauth/authorize", mock_server.base_url()), 1178 + token_endpoint: format!("{}/oauth/token", mock_server.base_url()), 1179 + pushed_authorization_request_endpoint: None, 1180 + response_types_supported: vec!["code".to_string()], 1181 + grant_types_supported: vec!["authorization_code".to_string()], 1182 + code_challenge_methods_supported: vec!["S256".to_string()], 1183 + dpop_signing_alg_values_supported: None, 1184 + scopes_supported: None, 1185 + }; 1186 + 1187 + let client = PdsClient::new(); 1188 + let result = client 1189 + .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof") 1190 + .await; 1191 + 1192 + assert!(result.is_ok()); 1193 + let response = result.unwrap(); 1194 + assert_eq!(response.status().as_u16(), 200); 1195 + } 1196 + 1197 + /// AC3.6: Token exchange returns raw response on non-2xx 1198 + #[tokio::test] 1199 + async fn test_pds_token_exchange_returns_raw_response_on_error() { 1200 + let mock_server = MockServer::start(); 1201 + 1202 + mock_server.mock(|when, then| { 1203 + when.method(httpmock::Method::POST).path("/oauth/token"); 1204 + then.status(400).json_body(serde_json::json!({ 1205 + "error": "use_dpop_nonce", 1206 + "error_description": "nonce required" 1207 + })); 1208 + }); 1209 + 1210 + let metadata = AuthServerMetadata { 1211 + issuer: mock_server.base_url(), 1212 + authorization_endpoint: format!("{}/oauth/authorize", mock_server.base_url()), 1213 + token_endpoint: format!("{}/oauth/token", mock_server.base_url()), 1214 + pushed_authorization_request_endpoint: None, 1215 + response_types_supported: vec!["code".to_string()], 1216 + grant_types_supported: vec!["authorization_code".to_string()], 1217 + code_challenge_methods_supported: vec!["S256".to_string()], 1218 + dpop_signing_alg_values_supported: None, 1219 + scopes_supported: None, 1220 + }; 1221 + 1222 + let client = PdsClient::new(); 1223 + let result = client 1224 + .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof") 1225 + .await; 1226 + 1227 + // Should return Ok(Response) with 400 status — caller handles error interpretation. 1228 + assert!(result.is_ok()); 1229 + let response = result.unwrap(); 1230 + assert_eq!(response.status().as_u16(), 400); 1231 + } 1232 + 1233 + /// AC3.6: Token exchange to unreachable endpoint returns OAuthFailed 1234 + #[tokio::test] 1235 + async fn test_pds_token_exchange_unreachable_endpoint() { 1236 + let metadata = AuthServerMetadata { 1237 + issuer: "http://127.0.0.1:1".to_string(), 1238 + authorization_endpoint: "http://127.0.0.1:1/oauth/authorize".to_string(), 1239 + token_endpoint: "http://127.0.0.1:1/oauth/token".to_string(), 1240 + pushed_authorization_request_endpoint: None, 1241 + response_types_supported: vec!["code".to_string()], 1242 + grant_types_supported: vec!["authorization_code".to_string()], 1243 + code_challenge_methods_supported: vec!["S256".to_string()], 1244 + dpop_signing_alg_values_supported: None, 1245 + scopes_supported: None, 1246 + }; 1247 + 1248 + let client = PdsClient::new(); 1249 + let result = client 1250 + .pds_token_exchange(&metadata, "test_code", "test_verifier", "test_dpop_proof") 1251 + .await; 1252 + 1253 + assert!(result.is_err()); 1254 + match result.unwrap_err() { 1255 + PdsClientError::OAuthFailed { .. } => { 1256 + // Expected 1257 + } 1258 + e => panic!("Expected OAuthFailed, got: {:?}", e), 1259 + } 1260 + } 1261 + 1262 + /// build_pds_authorize_url constructs correct URL 1263 + #[test] 1264 + fn test_build_pds_authorize_url_with_login_hint() { 1265 + let metadata = AuthServerMetadata { 1266 + issuer: "https://pds.example.com".to_string(), 1267 + authorization_endpoint: "https://pds.example.com/oauth/authorize".to_string(), 1268 + token_endpoint: "https://pds.example.com/oauth/token".to_string(), 1269 + pushed_authorization_request_endpoint: None, 1270 + response_types_supported: vec!["code".to_string()], 1271 + grant_types_supported: vec!["authorization_code".to_string()], 1272 + code_challenge_methods_supported: vec!["S256".to_string()], 1273 + dpop_signing_alg_values_supported: None, 1274 + scopes_supported: None, 1275 + }; 1276 + 1277 + let url = PdsClient::build_pds_authorize_url( 1278 + &metadata, 1279 + "urn:ietf:params:oauth:request_uri:test", 1280 + Some("user@example.com"), 1281 + ); 1282 + 1283 + assert!(url.contains("client_id=dev.malpercio.identitywallet")); 1284 + assert!(url.contains("request_uri=")); 1285 + assert!(url.contains("login_hint=")); 1286 + assert!(url.starts_with("https://pds.example.com/oauth/authorize?")); 1287 + } 1288 + 1289 + /// build_pds_authorize_url without login_hint 1290 + #[test] 1291 + fn test_build_pds_authorize_url_without_login_hint() { 1292 + let metadata = AuthServerMetadata { 1293 + issuer: "https://pds.example.com".to_string(), 1294 + authorization_endpoint: "https://pds.example.com/oauth/authorize".to_string(), 1295 + token_endpoint: "https://pds.example.com/oauth/token".to_string(), 1296 + pushed_authorization_request_endpoint: None, 1297 + response_types_supported: vec!["code".to_string()], 1298 + grant_types_supported: vec!["authorization_code".to_string()], 1299 + code_challenge_methods_supported: vec!["S256".to_string()], 1300 + dpop_signing_alg_values_supported: None, 1301 + scopes_supported: None, 1302 + }; 1303 + 1304 + let url = PdsClient::build_pds_authorize_url( 1305 + &metadata, 1306 + "urn:ietf:params:oauth:request_uri:test2", 1307 + None, 1308 + ); 1309 + 1310 + assert!(url.contains("client_id=dev.malpercio.identitywallet")); 1311 + assert!(url.contains("request_uri=")); 1312 + assert!(!url.contains("login_hint=")); 1313 + assert!(url.starts_with("https://pds.example.com/oauth/authorize?")); 1314 + } 1315 + 1316 + // ============================================================================ 1317 + // XRPC identity method tests 1318 + // ============================================================================ 1319 + 1320 + /// request_plc_operation_signature sends correct request 1321 + #[tokio::test] 1322 + async fn test_request_plc_operation_signature_success() { 1323 + use std::sync::{Arc, Mutex}; 1324 + 1325 + let mock_server = MockServer::start(); 1326 + 1327 + mock_server.mock(|when, then| { 1328 + when.method(httpmock::Method::POST) 1329 + .path("/xrpc/com.atproto.identity.requestPlcOperationSignature") 1330 + .header_exists("Authorization") 1331 + .header_exists("DPoP"); 1332 + then.status(200).json_body(serde_json::json!({})); 1333 + }); 1334 + 1335 + // Create a test session and OAuthClient 1336 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1337 + access_token: "test_access_token".to_string(), 1338 + refresh_token: "test_refresh_token".to_string(), 1339 + expires_at: std::time::SystemTime::now() 1340 + .duration_since(std::time::UNIX_EPOCH) 1341 + .unwrap() 1342 + .as_secs() 1343 + + 3600, 1344 + dpop_nonce: None, 1345 + })); 1346 + 1347 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1348 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1349 + keypair, 1350 + session, 1351 + mock_server.base_url(), 1352 + ); 1353 + 1354 + let result = request_plc_operation_signature(&oauth_client).await; 1355 + assert!(result.is_ok()); 1356 + } 1357 + 1358 + /// request_plc_operation_signature handles error 1359 + #[tokio::test] 1360 + async fn test_request_plc_operation_signature_error() { 1361 + use std::sync::{Arc, Mutex}; 1362 + 1363 + let mock_server = MockServer::start(); 1364 + 1365 + mock_server.mock(|when, then| { 1366 + when.method(httpmock::Method::POST) 1367 + .path("/xrpc/com.atproto.identity.requestPlcOperationSignature"); 1368 + then.status(401).json_body(serde_json::json!({ 1369 + "error": "Unauthorized" 1370 + })); 1371 + }); 1372 + 1373 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1374 + access_token: "test_access_token".to_string(), 1375 + refresh_token: "test_refresh_token".to_string(), 1376 + expires_at: std::time::SystemTime::now() 1377 + .duration_since(std::time::UNIX_EPOCH) 1378 + .unwrap() 1379 + .as_secs() 1380 + + 3600, 1381 + dpop_nonce: None, 1382 + })); 1383 + 1384 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1385 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1386 + keypair, 1387 + session, 1388 + mock_server.base_url(), 1389 + ); 1390 + 1391 + let result = request_plc_operation_signature(&oauth_client).await; 1392 + assert!(result.is_err()); 1393 + match result.unwrap_err() { 1394 + PdsClientError::NetworkError { .. } => { 1395 + // Expected 1396 + } 1397 + e => panic!("Expected NetworkError, got: {:?}", e), 1398 + } 1399 + } 1400 + 1401 + /// sign_plc_operation sends token and rotation keys 1402 + #[tokio::test] 1403 + async fn test_sign_plc_operation_success() { 1404 + use std::sync::{Arc, Mutex}; 1405 + 1406 + let mock_server = MockServer::start(); 1407 + 1408 + mock_server.mock(|when, then| { 1409 + when.method(httpmock::Method::POST) 1410 + .path("/xrpc/com.atproto.identity.signPlcOperation") 1411 + .header_exists("Authorization") 1412 + .header_exists("DPoP"); 1413 + then.status(200).json_body(serde_json::json!({ 1414 + "operation": { 1415 + "type": "plc_operation", 1416 + "prev": "bafytest123" 1417 + } 1418 + })); 1419 + }); 1420 + 1421 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1422 + access_token: "test_access_token".to_string(), 1423 + refresh_token: "test_refresh_token".to_string(), 1424 + expires_at: std::time::SystemTime::now() 1425 + .duration_since(std::time::UNIX_EPOCH) 1426 + .unwrap() 1427 + .as_secs() 1428 + + 3600, 1429 + dpop_nonce: None, 1430 + })); 1431 + 1432 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1433 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1434 + keypair, 1435 + session, 1436 + mock_server.base_url(), 1437 + ); 1438 + 1439 + let request = SignPlcOperationRequest { 1440 + token: "test_email_token".to_string(), 1441 + rotation_keys: Some(vec!["did:key:zQ3test1".to_string()]), 1442 + also_known_as: None, 1443 + verification_methods: None, 1444 + services: None, 1445 + }; 1446 + 1447 + let result = sign_plc_operation(&oauth_client, &request).await; 1448 + assert!(result.is_ok()); 1449 + let response = result.unwrap(); 1450 + assert!(response.operation.get("type").is_some()); 1451 + } 1452 + 1453 + /// sign_plc_operation omits optional null fields 1454 + #[tokio::test] 1455 + async fn test_sign_plc_operation_omits_none_fields() { 1456 + use std::sync::{Arc, Mutex}; 1457 + 1458 + let mock_server = MockServer::start(); 1459 + 1460 + let mock = mock_server.mock(|when, then| { 1461 + when.method(httpmock::Method::POST) 1462 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1463 + then.status(200).json_body(serde_json::json!({ 1464 + "operation": {} 1465 + })); 1466 + }); 1467 + 1468 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1469 + access_token: "test_access_token".to_string(), 1470 + refresh_token: "test_refresh_token".to_string(), 1471 + expires_at: std::time::SystemTime::now() 1472 + .duration_since(std::time::UNIX_EPOCH) 1473 + .unwrap() 1474 + .as_secs() 1475 + + 3600, 1476 + dpop_nonce: None, 1477 + })); 1478 + 1479 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1480 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1481 + keypair, 1482 + session, 1483 + mock_server.base_url(), 1484 + ); 1485 + 1486 + let request = SignPlcOperationRequest { 1487 + token: "test_token".to_string(), 1488 + rotation_keys: None, 1489 + also_known_as: None, 1490 + verification_methods: None, 1491 + services: None, 1492 + }; 1493 + 1494 + let result = sign_plc_operation(&oauth_client, &request).await; 1495 + assert!(result.is_ok()); 1496 + 1497 + // Verify the mock was hit (request was made) 1498 + assert_eq!(mock.hits(), 1); 1499 + } 1500 + 1501 + /// get_recommended_did_credentials returns credentials 1502 + #[tokio::test] 1503 + async fn test_get_recommended_did_credentials_success() { 1504 + use std::sync::{Arc, Mutex}; 1505 + 1506 + let mock_server = MockServer::start(); 1507 + 1508 + mock_server.mock(|when, then| { 1509 + when.method(httpmock::Method::GET) 1510 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials") 1511 + .header_exists("Authorization") 1512 + .header_exists("DPoP"); 1513 + then.status(200).json_body(serde_json::json!({ 1514 + "rotationKeys": ["did:key:zQ3test1"], 1515 + "alsoKnownAs": ["at://alice.test"] 1516 + })); 1517 + }); 1518 + 1519 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1520 + access_token: "test_access_token".to_string(), 1521 + refresh_token: "test_refresh_token".to_string(), 1522 + expires_at: std::time::SystemTime::now() 1523 + .duration_since(std::time::UNIX_EPOCH) 1524 + .unwrap() 1525 + .as_secs() 1526 + + 3600, 1527 + dpop_nonce: None, 1528 + })); 1529 + 1530 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1531 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1532 + keypair, 1533 + session, 1534 + mock_server.base_url(), 1535 + ); 1536 + 1537 + let result = get_recommended_did_credentials(&oauth_client).await; 1538 + assert!(result.is_ok()); 1539 + let creds = result.unwrap(); 1540 + assert!(creds.rotation_keys.is_some()); 1541 + assert!(creds.also_known_as.is_some()); 812 1542 } 813 1543 }