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): start_oauth_flow command, deep-link handler, token exchange (MM-149 phase 5)

authored by

Malpercio and committed by
Tangled
af98556c 428ce5ca

+414 -18
+1
apps/identity-wallet/src-tauri/Cargo.toml
··· 34 34 base64 = { workspace = true } 35 35 rand_core = { workspace = true } 36 36 uuid = { workspace = true } 37 + tokio = { workspace = true } 37 38 38 39 [dev-dependencies] 39 40 tokio = { version = "1", features = ["macros", "rt"] }
+57 -1
apps/identity-wallet/src-tauri/src/http.rs
··· 21 21 pub expires_in: u32, 22 22 } 23 23 24 + /// Successful response from `POST /oauth/token` (RFC 6749 §5.1). 25 + #[derive(Debug, serde::Deserialize)] 26 + pub struct TokenResponse { 27 + pub access_token: String, 28 + pub token_type: String, 29 + pub expires_in: u64, 30 + pub refresh_token: String, 31 + pub scope: String, 32 + } 33 + 34 + /// Error response from `POST /oauth/token` (RFC 6749 §5.2). 35 + #[derive(Debug, serde::Deserialize)] 36 + pub struct TokenErrorResponse { 37 + pub error: String, 38 + pub error_description: Option<String>, 39 + } 40 + 24 41 /// HTTP client for relay API requests. 25 42 pub struct RelayClient { 26 43 client: Client, ··· 94 111 let hint_owned; 95 112 let mut fields = vec![ 96 113 ("client_id", "dev.malpercio.identitywallet"), 97 - ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 114 + ( 115 + "redirect_uri", 116 + "dev.malpercio.identitywallet:/oauth/callback", 117 + ), 98 118 ("code_challenge", code_challenge), 99 119 ("code_challenge_method", "S256"), 100 120 ("state", state_param), ··· 131 151 tracing::error!(error = %e, "PAR response deserialization failed"); 132 152 OAuthError::ParFailed 133 153 }) 154 + } 155 + 156 + /// POST `/oauth/token` — exchange an authorization code for tokens. 157 + /// 158 + /// Sends the authorization code, PKCE verifier, and DPoP proof. 159 + /// Returns the token response body on 200, or an error. 160 + /// The caller is responsible for reading the `DPoP-Nonce` response header 161 + /// if the server returns one (the full `reqwest::Response` is returned for this). 162 + pub async fn token_exchange( 163 + &self, 164 + code: &str, 165 + pkce_verifier: &str, 166 + dpop_proof: &str, 167 + ) -> Result<reqwest::Response, OAuthError> { 168 + let url = format!("{}/oauth/token", self.base_url); 169 + let resp = self 170 + .client 171 + .post(&url) 172 + .header("DPoP", dpop_proof) 173 + .form(&[ 174 + ("grant_type", "authorization_code"), 175 + ("code", code), 176 + ( 177 + "redirect_uri", 178 + "dev.malpercio.identitywallet:/oauth/callback", 179 + ), 180 + ("client_id", "dev.malpercio.identitywallet"), 181 + ("code_verifier", pkce_verifier), 182 + ]) 183 + .send() 184 + .await 185 + .map_err(|e| { 186 + tracing::error!(error = %e, "token exchange network error"); 187 + OAuthError::TokenExchangeFailed 188 + })?; 189 + Ok(resp) 134 190 } 135 191 136 192 /// Returns the compile-time base URL for this relay client instance.
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 417 417 get_or_create_device_key, 418 418 sign_with_device_key, 419 419 perform_did_ceremony, 420 + oauth::start_oauth_flow, 420 421 ]) 421 422 .run(tauri::generate_context!()) 422 423 .expect("error while running tauri application");
+355 -17
apps/identity-wallet/src-tauri/src/oauth.rs
··· 244 244 URL_SAFE_NO_PAD.encode(bytes) 245 245 } 246 246 247 - // ── Pending flow (stub — filled out in Phase 5) ─────────────────────────────── 247 + // ── Pending flow ────────────────────────────────────────────────────────────── 248 248 249 249 /// State parked inside `AppState.pending_auth` while `start_oauth_flow` waits 250 250 /// for the deep-link callback. 251 - /// 252 - /// Phase 5 adds: oneshot::Sender<CallbackParams>, pkce_verifier, csrf_state. 253 251 pub struct PendingOAuthFlow { 254 - /// The CSRF state parameter generated at the start of the flow. 255 - /// Used by `handle_deep_link` to validate the callback state. 252 + /// Channel to deliver the callback result back to `start_oauth_flow`. 253 + /// 254 + /// Sends `Ok(CallbackParams)` on success or `Err(OAuthError::StateMismatch)` on 255 + /// CSRF mismatch, so the command can distinguish a mismatch from a dropped channel. 256 + pub tx: tokio::sync::oneshot::Sender<Result<CallbackParams, OAuthError>>, 257 + /// PKCE code_verifier to include in the token exchange. 258 + pub pkce_verifier: String, 259 + /// CSRF state parameter — validated against the callback's state param. 256 260 pub csrf_state: String, 257 261 } 258 262 259 - // ── OAuth session (stub — filled out in Phase 5) ────────────────────────────── 263 + // ── OAuth session ───────────────────────────────────────────────────────────── 260 264 261 - /// Active OAuth session stored after a successful token exchange. 262 - /// 263 - /// Phase 5 adds: access_token, refresh_token, expires_at, dpop_nonce. 265 + /// Active OAuth session stored in AppState after successful token exchange. 264 266 pub struct OAuthSession { 265 267 pub access_token: String, 266 268 pub refresh_token: String, 269 + /// Unix timestamp (seconds) when the access token expires. 270 + pub expires_at: u64, 271 + /// The most recent DPoP nonce issued by the server. 272 + /// Starts as None; updated whenever the server sends a DPoP-Nonce header. 273 + pub dpop_nonce: Option<String>, 267 274 } 268 275 269 276 // ── Callback params ─────────────────────────────────────────────────────────── ··· 278 285 279 286 /// Process URLs received from the deep-link plugin's `on_open_url` event. 280 287 /// 281 - /// Filters for the OAuth callback path and logs receipt. Phase 5 completes this 282 - /// by extracting `code`+`state` and sending them on the pending `oneshot` channel. 288 + /// Filters for the OAuth callback path, extracts `code` and `state`, validates the 289 + /// CSRF state against the pending flow, and sends `CallbackParams` on the oneshot channel. 290 + /// 291 + /// Called from the `on_open_url` closure in lib.rs (sync context — no async). 292 + /// A second callback (replay) is silently ignored because `pending_auth.take()` clears 293 + /// the slot on first receipt. 283 294 pub fn handle_deep_link(urls: Vec<url::Url>, app_state: &AppState) { 284 295 for url in &urls { 285 296 let scheme = url.scheme(); ··· 288 299 if scheme == "dev.malpercio.identitywallet" && path == "/oauth/callback" { 289 300 tracing::info!(url = %url, "OAuth deep-link callback received"); 290 301 291 - // Phase 5: extract code+state, validate CSRF, send on oneshot channel. 292 - // For now, just log that the callback arrived. 293 - // Panic on poison: a panic while holding this lock is a programming error 294 - // with no safe recovery path. 295 - let _pending = app_state.pending_auth.lock().unwrap(); 296 - tracing::info!("pending_auth slot present: {}", _pending.is_some()); 302 + // Take the pending flow — clears the slot so replays are silently ignored. 303 + let pending = app_state.pending_auth.lock().unwrap().take(); 304 + let Some(flow) = pending else { 305 + tracing::warn!( 306 + "OAuth callback received but no flow is pending; ignoring (replay?)" 307 + ); 308 + return; 309 + }; 310 + 311 + // Extract code and state from query parameters. 312 + let mut code_opt: Option<String> = None; 313 + let mut state_opt: Option<String> = None; 314 + for (key, value) in url.query_pairs() { 315 + match key.as_ref() { 316 + "code" => code_opt = Some(value.into_owned()), 317 + "state" => state_opt = Some(value.into_owned()), 318 + _ => {} 319 + } 320 + } 321 + 322 + let (Some(code), Some(callback_state)) = (code_opt, state_opt) else { 323 + tracing::error!("OAuth callback URL missing code or state parameters"); 324 + return; 325 + }; 297 326 327 + // Validate CSRF state — must match before sending on the channel. 328 + if callback_state != flow.csrf_state { 329 + tracing::error!( 330 + expected = %flow.csrf_state, 331 + received = %callback_state, 332 + "CSRF state mismatch in OAuth callback; aborting flow" 333 + ); 334 + // Send the error explicitly so start_oauth_flow returns StateMismatch, 335 + // not CallbackAbandoned (which would occur if we just dropped tx). 336 + let _ = flow.tx.send(Err(OAuthError::StateMismatch)); 337 + return; 338 + } 339 + 340 + let _ = flow.tx.send(Ok(CallbackParams { 341 + code, 342 + state: callback_state, 343 + })); 298 344 return; 299 345 } 300 346 ··· 302 348 } 303 349 } 304 350 351 + // ── Tauri command ───────────────────────────────────────────────────────────── 352 + 353 + /// Drive the full OAuth 2.0 PKCE + DPoP authorization round-trip. 354 + /// 355 + /// Called from the SvelteKit frontend via `invoke('start_oauth_flow')`. 356 + /// Parks on a Tokio oneshot channel until `handle_deep_link` delivers 357 + /// the authorization code from the system browser redirect. 358 + /// 359 + /// # Flow 360 + /// 1. Generate PKCE verifier/challenge and CSRF state parameter 361 + /// 2. Get-or-create DPoP keypair; build PAR DPoP proof 362 + /// 3. POST /oauth/par → receive request_uri 363 + /// 4. Open system browser to /oauth/authorize?client_id=...&request_uri=... 364 + /// 5. Park on oneshot receiver; handle_deep_link will send the code+state 365 + /// 6. Validate CSRF state matches 366 + /// 7. POST /oauth/token (authorization_code grant + PKCE verifier + DPoP proof) 367 + /// → on use_dpop_nonce 400: retry with server-issued nonce 368 + /// 8. Store access_token + refresh_token in Keychain 369 + /// 9. Populate AppState.oauth_session 370 + #[tauri::command] 371 + pub async fn start_oauth_flow( 372 + app: tauri::AppHandle, 373 + state: tauri::State<'_, AppState>, 374 + login_hint: Option<String>, 375 + ) -> Result<(), OAuthError> { 376 + // OpenerExt adds the `.opener()` method to AppHandle. 377 + use tauri_plugin_opener::OpenerExt; 378 + 379 + let relay = crate::http::RelayClient::new(); 380 + 381 + // 1. Generate PKCE and CSRF state. 382 + let (pkce_verifier, pkce_challenge) = pkce::generate(); 383 + let csrf_state = generate_state_param(); 384 + 385 + // 2. Get-or-create DPoP keypair. 386 + let dpop = DPoPKeypair::get_or_create()?; 387 + let dpop_jkt = dpop.public_jwk_thumbprint(); 388 + 389 + let par_htu = format!("{}/oauth/par", crate::http::RelayClient::base_url()); 390 + let par_proof = dpop.make_proof("POST", &par_htu, None, None)?; 391 + 392 + // 3. PAR call. 393 + let par_resp = relay 394 + .par( 395 + &pkce_challenge, 396 + &csrf_state, 397 + &par_proof, 398 + &dpop_jkt, 399 + login_hint.as_deref(), 400 + ) 401 + .await?; 402 + 403 + // 4. Set up the oneshot channel and park pending_auth. 404 + let (tx, rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 405 + { 406 + let mut pending = state.pending_auth.lock().unwrap(); 407 + *pending = Some(PendingOAuthFlow { 408 + tx, 409 + pkce_verifier: pkce_verifier.clone(), 410 + csrf_state: csrf_state.clone(), 411 + }); 412 + } // Mutex guard dropped here — not held across .await. 413 + 414 + // 5. Open Safari to the authorization endpoint. 415 + let auth_url = { 416 + let base = crate::http::RelayClient::base_url(); 417 + let request_uri_encoded = 418 + url::form_urlencoded::byte_serialize(par_resp.request_uri.as_bytes()) 419 + .collect::<String>(); 420 + let mut u = format!( 421 + "{base}/oauth/authorize?client_id=dev.malpercio.identitywallet&request_uri={request_uri_encoded}" 422 + ); 423 + if let Some(hint) = &login_hint { 424 + let hint_encoded = 425 + url::form_urlencoded::byte_serialize(hint.as_bytes()).collect::<String>(); 426 + u.push_str(&format!("&login_hint={hint_encoded}")); 427 + } 428 + u 429 + }; 430 + 431 + app.opener() 432 + .open_url(&auth_url, None::<&str>) 433 + .map_err(|e| { 434 + tracing::error!(error = %e, "failed to open system browser for OAuth"); 435 + OAuthError::ParFailed 436 + })?; 437 + 438 + // 6. Wait for the deep-link callback to deliver the authorization code. 439 + // The outer ? handles RecvError (channel dropped) → CallbackAbandoned. 440 + // The inner ? propagates OAuthError::StateMismatch if handle_deep_link detected a CSRF mismatch. 441 + let callback = rx.await.map_err(|_| OAuthError::CallbackAbandoned)??; 442 + 443 + // 7. Token exchange. 444 + let token_htu = format!("{}/oauth/token", crate::http::RelayClient::base_url()); 445 + let (token_resp, initial_nonce) = 446 + exchange_code_with_retry(&relay, &dpop, &callback.code, &pkce_verifier, &token_htu).await?; 447 + 448 + // 8. Store tokens in Keychain. 449 + crate::keychain::store_oauth_tokens(&token_resp.access_token, &token_resp.refresh_token) 450 + .map_err(|_| OAuthError::KeychainError)?; 451 + 452 + // 9. Update AppState. 453 + // Seed dpop_nonce from the token response to avoid a guaranteed use_dpop_nonce retry 454 + // on the first OAuthClient request immediately after login. 455 + let expires_at = std::time::SystemTime::now() 456 + .duration_since(std::time::UNIX_EPOCH) 457 + .map_err(|_| OAuthError::TokenExchangeFailed)? 458 + .as_secs() 459 + + token_resp.expires_in; 460 + 461 + let mut session = state.oauth_session.lock().unwrap(); 462 + *session = Some(OAuthSession { 463 + access_token: token_resp.access_token, 464 + refresh_token: token_resp.refresh_token, 465 + expires_at, 466 + dpop_nonce: initial_nonce, 467 + }); 468 + 469 + tracing::info!("OAuth flow complete; session stored"); 470 + Ok(()) 471 + } 472 + 473 + /// Perform the authorization code token exchange with one retry on `use_dpop_nonce`. 474 + /// 475 + /// Returns the token response and the `DPoP-Nonce` header value from the successful 476 + /// response (if present). Storing this nonce in the session avoids a guaranteed 477 + /// `use_dpop_nonce` retry on the very first `OAuthClient` request after login. 478 + /// 479 + /// The relay always requires a DPoP nonce at the token endpoint (RFC 9449 §8). 480 + /// On the first attempt, the nonce is absent; the relay returns 400 with `use_dpop_nonce` 481 + /// and a `DPoP-Nonce` response header. We retry exactly once with that nonce. 482 + async fn exchange_code_with_retry( 483 + relay: &crate::http::RelayClient, 484 + dpop: &DPoPKeypair, 485 + code: &str, 486 + pkce_verifier: &str, 487 + token_htu: &str, 488 + ) -> Result<(crate::http::TokenResponse, Option<String>), OAuthError> { 489 + let proof = dpop.make_proof("POST", token_htu, None, None)?; 490 + let resp = relay.token_exchange(code, pkce_verifier, &proof).await?; 491 + 492 + if resp.status().as_u16() == 200 { 493 + // Capture DPoP-Nonce before consuming the body. 494 + let nonce = resp 495 + .headers() 496 + .get("DPoP-Nonce") 497 + .and_then(|v| v.to_str().ok()) 498 + .map(str::to_string); 499 + let token = resp 500 + .json::<crate::http::TokenResponse>() 501 + .await 502 + .map_err(|e| { 503 + tracing::error!(error = %e, "token response deserialization failed"); 504 + OAuthError::TokenExchangeFailed 505 + })?; 506 + return Ok((token, nonce)); 507 + } 508 + 509 + // Check for use_dpop_nonce — extract the nonce from the DPoP-Nonce header. 510 + let nonce = resp 511 + .headers() 512 + .get("DPoP-Nonce") 513 + .and_then(|v| v.to_str().ok()) 514 + .map(str::to_string); 515 + 516 + let error_body = resp 517 + .json::<crate::http::TokenErrorResponse>() 518 + .await 519 + .unwrap_or_else(|_| crate::http::TokenErrorResponse { 520 + error: "unknown".into(), 521 + error_description: None, 522 + }); 523 + 524 + if error_body.error == "use_dpop_nonce" { 525 + if let Some(nonce_val) = nonce { 526 + tracing::debug!(nonce = %nonce_val, "retrying token exchange with server nonce"); 527 + let proof_with_nonce = dpop.make_proof("POST", token_htu, Some(&nonce_val), None)?; 528 + let retry_resp = relay 529 + .token_exchange(code, pkce_verifier, &proof_with_nonce) 530 + .await?; 531 + if retry_resp.status().as_u16() == 200 { 532 + // Capture DPoP-Nonce from the retry response too. 533 + let retry_nonce = retry_resp 534 + .headers() 535 + .get("DPoP-Nonce") 536 + .and_then(|v| v.to_str().ok()) 537 + .map(str::to_string); 538 + let token = retry_resp 539 + .json::<crate::http::TokenResponse>() 540 + .await 541 + .map_err(|e| { 542 + tracing::error!(error = %e, "retry token response deserialization failed"); 543 + OAuthError::TokenExchangeFailed 544 + })?; 545 + return Ok((token, retry_nonce)); 546 + } 547 + tracing::error!("token exchange failed after nonce retry"); 548 + return Err(OAuthError::TokenExchangeFailed); 549 + } 550 + } 551 + 552 + tracing::error!(error = %error_body.error, "token exchange failed"); 553 + Err(OAuthError::TokenExchangeFailed) 554 + } 555 + 305 556 #[cfg(test)] 306 557 mod tests { 307 558 use super::*; ··· 591 842 "relay must reject PAR without code_challenge with 4xx, got: {}", 592 843 resp.status() 593 844 ); 845 + } 846 + 847 + // handle_deep_link tests 848 + fn make_test_url(code: &str, state: &str) -> url::Url { 849 + url::Url::parse(&format!( 850 + "dev.malpercio.identitywallet:/oauth/callback?code={code}&state={state}" 851 + )) 852 + .unwrap() 853 + } 854 + 855 + #[test] 856 + fn handle_deep_link_csrf_mismatch_returns_state_mismatch_error() { 857 + let (tx, mut rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 858 + let state = AppState { 859 + pending_auth: std::sync::Mutex::new(Some(PendingOAuthFlow { 860 + tx, 861 + pkce_verifier: "v".to_string(), 862 + csrf_state: "correct-state".to_string(), 863 + })), 864 + oauth_session: std::sync::Mutex::new(None), 865 + }; 866 + 867 + let url = make_test_url("code123", "WRONG-STATE"); 868 + handle_deep_link(vec![url], &state); 869 + 870 + // Receiver must get Err(StateMismatch), not a channel-level error. 871 + assert!( 872 + matches!(rx.try_recv(), Ok(Err(OAuthError::StateMismatch))), 873 + "CSRF mismatch must deliver StateMismatch to the command" 874 + ); 875 + // The pending_auth slot was cleared. 876 + assert!( 877 + state.pending_auth.lock().unwrap().is_none(), 878 + "pending_auth must be cleared" 879 + ); 880 + } 881 + 882 + #[test] 883 + fn handle_deep_link_replay_is_silently_ignored() { 884 + let (tx, mut rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 885 + let state = AppState { 886 + pending_auth: std::sync::Mutex::new(Some(PendingOAuthFlow { 887 + tx, 888 + pkce_verifier: "v".to_string(), 889 + csrf_state: "good-state".to_string(), 890 + })), 891 + oauth_session: std::sync::Mutex::new(None), 892 + }; 893 + 894 + // First callback succeeds. 895 + let url = make_test_url("code123", "good-state"); 896 + handle_deep_link(vec![url.clone()], &state); 897 + assert!( 898 + matches!(rx.try_recv(), Ok(Ok(_))), 899 + "first callback must deliver the code" 900 + ); 901 + 902 + // Second callback (replay) — pending_auth is now None. 903 + handle_deep_link(vec![url], &state); // must not panic 904 + // pending_auth is still None. 905 + assert!( 906 + state.pending_auth.lock().unwrap().is_none(), 907 + "replay must not re-populate pending_auth" 908 + ); 909 + } 910 + 911 + #[test] 912 + fn handle_deep_link_delivers_code_and_state() { 913 + let (tx, mut rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 914 + let state = AppState { 915 + pending_auth: std::sync::Mutex::new(Some(PendingOAuthFlow { 916 + tx, 917 + pkce_verifier: "v".to_string(), 918 + csrf_state: "expected-state".to_string(), 919 + })), 920 + oauth_session: std::sync::Mutex::new(None), 921 + }; 922 + 923 + let url = make_test_url("mycode", "expected-state"); 924 + handle_deep_link(vec![url], &state); 925 + 926 + let params = rx 927 + .try_recv() 928 + .expect("channel must not be empty") 929 + .expect("callback must succeed"); 930 + assert_eq!(params.code, "mycode"); 931 + assert_eq!(params.state, "expected-state"); 594 932 } 595 933 }