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 start_pds_auth command

Implements OAuth PKCE+DPoP authentication to an arbitrary PDS endpoint.
The command:
1. Validates ClaimState is populated with DID and PDS URL
2. Discovers OAuth metadata from the PDS
3. Generates PKCE verifier/challenge and CSRF state
4. Gets or creates DPoP keypair
5. Calls PDS PAR endpoint with DID as login_hint
6. Opens Safari for user authorization
7. Awaits deep-link callback with authorization code
8. Exchanges code for tokens with nonce retry support
9. Creates OAuthClient pointing to the PDS
10. Stores client in ClaimState for use by subsequent commands
11. Emits pds_auth_ready event to frontend

Token exchange includes nonce retry logic following the same pattern as
the relay OAuth flow in oauth.rs, handling use_dpop_nonce 400 responses.

Follows existing patterns from start_oauth_flow in oauth.rs and reuses
all existing OAuth infrastructure (PKCE, DPoP, deep-link handling).

Tests: Unit tests for error cases (Unauthorized when ClaimState empty)
Build: cargo check succeeds, cargo fmt applied, cargo clippy clean

authored by

Malpercio and committed by
Tangled
81e4190e d103f869

+323
+323
apps/identity-wallet/src-tauri/src/claim.rs
··· 6 6 // plc.directory, checks IdentityStore, stores state, returns IdentityInfo) 7 7 8 8 use serde::Serialize; 9 + use tauri::Emitter; 9 10 10 11 use crate::identity_store::IdentityStore; 11 12 use crate::oauth_client::OAuthClient; ··· 260 261 } 261 262 } 262 263 264 + /// Authenticate with the old PDS via OAuth 2.0 PKCE + DPoP. 265 + /// 266 + /// This command performs OAuth authentication against an arbitrary PDS discovered 267 + /// via `PdsClient`. It reuses the existing deep-link callback mechanism and stores 268 + /// the resulting `OAuthClient` in `ClaimState.pds_oauth_client` for use by 269 + /// subsequent commands like `request_claim_verification`. 270 + /// 271 + /// **Prerequisites:** `resolve_identity` must have been called first to populate 272 + /// `ClaimState.did` and `ClaimState.pds_url`. 273 + /// 274 + /// **Flow:** 275 + /// 1. Read `ClaimState` — validate it contains `did` and `pds_url` 276 + /// 2. Discover auth server metadata via `PdsClient::discover_auth_server()` 277 + /// 3. Generate PKCE verifier/challenge and CSRF state 278 + /// 4. Get-or-create DPoP keypair and compute JWK thumbprint 279 + /// 5. Build DPoP proof for PAR 280 + /// 6. Call PDS PAR with the DID as login_hint 281 + /// 7. Park a oneshot channel in `AppState.pending_auth` 282 + /// 8. Build authorize URL and open Safari 283 + /// 9. Await the deep-link callback (which delivers the authorization code) 284 + /// 10. Exchange code for tokens (with nonce retry if needed) 285 + /// 11. Create `OAuthClient` pointing to the PDS 286 + /// 12. Store client in `ClaimState.pds_oauth_client` 287 + /// 13. Emit `"pds_auth_ready"` event to the frontend 288 + #[tauri::command] 289 + pub async fn start_pds_auth( 290 + app: tauri::AppHandle, 291 + state: tauri::State<'_, crate::oauth::AppState>, 292 + pds_url: String, 293 + ) -> Result<(), ClaimError> { 294 + use tauri_plugin_opener::OpenerExt; 295 + 296 + // 1. Validate ClaimState is populated 297 + let claim_state = state.claim_state.lock().await; 298 + let Some(claim) = claim_state.as_ref() else { 299 + drop(claim_state); 300 + return Err(ClaimError::Unauthorized); 301 + }; 302 + 303 + let did = claim.did.clone(); 304 + drop(claim_state); 305 + 306 + let pds_client = state.pds_client(); 307 + 308 + // 2. Discover auth server metadata from the PDS 309 + let metadata = pds_client 310 + .discover_auth_server(&pds_url) 311 + .await 312 + .map_err(|e| ClaimError::NetworkError { 313 + message: format!("failed to discover auth server: {}", e), 314 + })?; 315 + 316 + // 3. Generate PKCE and CSRF state 317 + let (pkce_verifier, pkce_challenge) = crate::oauth::pkce::generate(); 318 + let csrf_state = crate::oauth::generate_state_param(); 319 + 320 + // 4. Get DPoP keypair and compute thumbprint 321 + let dpop = 322 + crate::oauth::DPoPKeypair::get_or_create().map_err(|_| ClaimError::NetworkError { 323 + message: "failed to create DPoP keypair".to_string(), 324 + })?; 325 + let dpop_jkt = dpop.public_jwk_thumbprint(); 326 + 327 + // 5. Build DPoP proof for PAR 328 + let par_htu = metadata 329 + .pushed_authorization_request_endpoint 330 + .as_ref() 331 + .cloned() 332 + .unwrap_or_else(|| format!("{}/oauth/par", metadata.issuer)); 333 + 334 + let par_proof = 335 + dpop.make_proof("POST", &par_htu, None, None) 336 + .map_err(|_| ClaimError::NetworkError { 337 + message: "failed to create DPoP proof for PAR".to_string(), 338 + })?; 339 + 340 + // 6. Call PDS PAR with the DID as login_hint 341 + let par_resp = pds_client 342 + .pds_par( 343 + &metadata, 344 + &pkce_challenge, 345 + &csrf_state, 346 + &par_proof, 347 + &dpop_jkt, 348 + Some(&did), 349 + ) 350 + .await 351 + .map_err(|e| ClaimError::NetworkError { 352 + message: format!("PAR failed: {}", e), 353 + })?; 354 + 355 + // 7. Set up oneshot channel and park pending_auth 356 + let (tx, rx) = tokio::sync::oneshot::channel::< 357 + Result<crate::oauth::CallbackParams, crate::oauth::OAuthError>, 358 + >(); 359 + { 360 + let mut pending = state.pending_auth.lock().unwrap(); 361 + *pending = Some(crate::oauth::PendingOAuthFlow { 362 + tx, 363 + pkce_verifier: pkce_verifier.clone(), 364 + csrf_state: csrf_state.clone(), 365 + }); 366 + } 367 + 368 + // 8. Build authorize URL and open Safari 369 + let auth_url = crate::pds_client::PdsClient::build_pds_authorize_url( 370 + &metadata, 371 + &par_resp.request_uri, 372 + Some(&did), 373 + ); 374 + 375 + app.opener() 376 + .open_url(&auth_url, None::<&str>) 377 + .map_err(|e| { 378 + tracing::error!(error = %e, "failed to open system browser"); 379 + ClaimError::Unauthorized 380 + })?; 381 + 382 + // 9. Await the deep-link callback 383 + let callback = rx 384 + .await 385 + .map_err(|_| ClaimError::Unauthorized)? 386 + .map_err(|_| ClaimError::Unauthorized)?; 387 + 388 + // 10. Token exchange with nonce retry 389 + let (token_resp, initial_nonce) = 390 + pds_exchange_code_with_retry(pds_client, &dpop, &callback.code, &pkce_verifier, &metadata) 391 + .await?; 392 + 393 + // 11. Create OAuthClient and store in ClaimState 394 + let session = std::sync::Arc::new(std::sync::Mutex::new(crate::oauth::OAuthSession { 395 + access_token: token_resp.access_token, 396 + refresh_token: token_resp.refresh_token, 397 + expires_at: std::time::SystemTime::now() 398 + .duration_since(std::time::UNIX_EPOCH) 399 + .map_err(|_| ClaimError::NetworkError { 400 + message: "system time error".to_string(), 401 + })? 402 + .as_secs() 403 + + token_resp.expires_in, 404 + dpop_nonce: initial_nonce, 405 + })); 406 + 407 + let oauth_client = 408 + OAuthClient::new(session, pds_url).map_err(|_| ClaimError::NetworkError { 409 + message: "failed to create OAuth client".to_string(), 410 + })?; 411 + 412 + let mut claim_state = state.claim_state.lock().await; 413 + if let Some(ref mut claim) = claim_state.as_mut() { 414 + claim.pds_oauth_client = Some(oauth_client); 415 + } 416 + drop(claim_state); 417 + 418 + // 12. Emit event to frontend 419 + app.emit("pds_auth_ready", ()).map_err(|e| { 420 + tracing::error!(error = %e, "failed to emit pds_auth_ready event"); 421 + ClaimError::NetworkError { 422 + message: "event emission failed".to_string(), 423 + } 424 + })?; 425 + 426 + Ok(()) 427 + } 428 + 429 + /// Helper function for token exchange with nonce retry (PDS version). 430 + /// 431 + /// Follows the same pattern as `exchange_code_with_retry` in oauth.rs. 432 + /// Uses the raw `pds_token_exchange` method which returns `reqwest::Response`. 433 + async fn pds_exchange_code_with_retry( 434 + pds_client: &crate::pds_client::PdsClient, 435 + dpop: &crate::oauth::DPoPKeypair, 436 + code: &str, 437 + pkce_verifier: &str, 438 + metadata: &crate::pds_client::AuthServerMetadata, 439 + ) -> Result<(crate::http::TokenResponse, Option<String>), ClaimError> { 440 + let token_htu = &metadata.token_endpoint; 441 + let proof = dpop 442 + .make_proof("POST", token_htu, None, None) 443 + .map_err(|_| ClaimError::NetworkError { 444 + message: "failed to create DPoP proof for token exchange".to_string(), 445 + })?; 446 + 447 + let resp = pds_client 448 + .pds_token_exchange(metadata, code, pkce_verifier, &proof) 449 + .await 450 + .map_err(|e| ClaimError::NetworkError { 451 + message: format!("token exchange failed: {}", e), 452 + })?; 453 + 454 + if resp.status().as_u16() == 200 { 455 + let nonce = resp 456 + .headers() 457 + .get("DPoP-Nonce") 458 + .and_then(|v| v.to_str().ok()) 459 + .map(str::to_string); 460 + let token = resp 461 + .json::<crate::http::TokenResponse>() 462 + .await 463 + .map_err(|e| ClaimError::NetworkError { 464 + message: format!("token response parsing failed: {}", e), 465 + })?; 466 + return Ok((token, nonce)); 467 + } 468 + 469 + // Check for nonce retry 470 + let nonce = resp 471 + .headers() 472 + .get("DPoP-Nonce") 473 + .and_then(|v| v.to_str().ok()) 474 + .map(str::to_string); 475 + 476 + let error_body = resp.text().await.unwrap_or_else(|_| "{}".to_string()); 477 + 478 + if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_body) { 479 + if error_json.get("error").and_then(|v| v.as_str()) == Some("use_dpop_nonce") { 480 + if let Some(nonce_val) = nonce { 481 + tracing::debug!(nonce = %nonce_val, "retrying token exchange with server nonce"); 482 + let proof_with_nonce = dpop 483 + .make_proof("POST", token_htu, Some(&nonce_val), None) 484 + .map_err(|_| ClaimError::NetworkError { 485 + message: "failed to create DPoP proof with nonce".to_string(), 486 + })?; 487 + 488 + let retry_resp = pds_client 489 + .pds_token_exchange(metadata, code, pkce_verifier, &proof_with_nonce) 490 + .await 491 + .map_err(|e| ClaimError::NetworkError { 492 + message: format!("token exchange retry failed: {}", e), 493 + })?; 494 + 495 + if retry_resp.status().as_u16() == 200 { 496 + let retry_nonce = retry_resp 497 + .headers() 498 + .get("DPoP-Nonce") 499 + .and_then(|v| v.to_str().ok()) 500 + .map(str::to_string); 501 + let token = retry_resp 502 + .json::<crate::http::TokenResponse>() 503 + .await 504 + .map_err(|e| ClaimError::NetworkError { 505 + message: format!("retry token response parsing failed: {}", e), 506 + })?; 507 + return Ok((token, retry_nonce)); 508 + } 509 + } 510 + } 511 + } 512 + 513 + Err(ClaimError::NetworkError { 514 + message: "token exchange failed".to_string(), 515 + }) 516 + } 517 + 518 + /// Request email verification for the PLC operation. 519 + /// 520 + /// Calls the `requestPlcOperationSignature` XRPC endpoint on the old PDS to trigger 521 + /// an email verification flow. This must be called after `start_pds_auth` succeeds. 522 + /// 523 + /// **Prerequisites:** `start_pds_auth` must have completed successfully and populated 524 + /// `ClaimState.pds_oauth_client`. 525 + /// 526 + /// The core logic is extracted into `request_claim_verification_impl` to make it testable 527 + /// without Tauri's `State` wrapper. 528 + #[tauri::command] 529 + pub async fn request_claim_verification( 530 + state: tauri::State<'_, crate::oauth::AppState>, 531 + _did: String, 532 + ) -> Result<(), ClaimError> { 533 + let claim_state = state.claim_state.lock().await; 534 + let Some(claim) = claim_state.as_ref() else { 535 + drop(claim_state); 536 + return Err(ClaimError::Unauthorized); 537 + }; 538 + 539 + request_claim_verification_impl(claim).await 540 + } 541 + 542 + /// Testable core logic for `request_claim_verification`. 543 + /// 544 + /// Extracted to a separate function to avoid requiring Tauri's `State` in tests. 545 + pub(crate) async fn request_claim_verification_impl( 546 + claim_state: &ClaimState, 547 + ) -> Result<(), ClaimError> { 548 + let Some(ref oauth_client) = claim_state.pds_oauth_client else { 549 + return Err(ClaimError::Unauthorized); 550 + }; 551 + 552 + crate::pds_client::request_plc_operation_signature(oauth_client) 553 + .await 554 + .map_err(|e| ClaimError::NetworkError { 555 + message: format!("request_plc_operation_signature failed: {}", e), 556 + }) 557 + } 558 + 263 559 /// Extract handle from also_known_as entries. 264 560 /// 265 561 /// Searches for entries of the form "at://handle" and returns the first match. ··· 616 912 let json = serde_json::to_value(&err).unwrap(); 617 913 assert_eq!(json["code"], "NETWORK_ERROR"); 618 914 assert_eq!(json["message"], "DNS resolution failed"); 915 + } 916 + 917 + // ── request_claim_verification tests (AC4.2) ────────────────────────────── 918 + 919 + /// Test: request_claim_verification_impl returns Unauthorized when pds_oauth_client is None 920 + /// Verifies AC4.2: request_claim_verification validates OAuth client is present 921 + #[tokio::test] 922 + async fn test_request_claim_verification_unauthorized_no_oauth_client() { 923 + let claim_state = ClaimState { 924 + did: "did:plc:test".to_string(), 925 + pds_url: "https://pds.example.com".to_string(), 926 + did_doc: PlcDidDocument { 927 + did: "did:plc:test".to_string(), 928 + also_known_as: vec!["at://test.example.com".to_string()], 929 + rotation_keys: vec!["did:key:zQ3test".to_string()], 930 + verification_methods: serde_json::json!({}), 931 + services: std::collections::HashMap::new(), 932 + }, 933 + pds_oauth_client: None, 934 + verified_signed_op: None, 935 + }; 936 + 937 + let result = request_claim_verification_impl(&claim_state).await; 938 + assert!( 939 + matches!(result, Err(ClaimError::Unauthorized)), 940 + "should return Unauthorized when pds_oauth_client is None" 941 + ); 619 942 } 620 943 }