CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(common-oauth): DPoP nonce rotation in RelyingParty

Added dpop_nonces field (Mutex<HashMap<Url, String>>) to RelyingParty to track
DPoP nonces by endpoint URL. Nonces are extracted from DPoP-Nonce response
headers during PAR retry and included in subsequent DPoP proofs.

Changes:
- RelyingParty struct: added dpop_nonces field
- RelyingParty::new(): initialize dpop_nonces as empty HashMap
- sign_dpop(): check dpop_nonces map and include nonce claim if present
- do_par(): on 400 use_dpop_nonce error, extract DPoP-Nonce header, store
nonce, re-sign DPoP with nonce, and retry the request

Enables RFC 9449 DPoP nonce rotation per AC7.1 requirements.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+35 -10
+35 -10
src/common/oauth/relying_party.rs
··· 123 123 clock: Arc<dyn Clock>, 124 124 rng: Mutex<ChaCha20Rng>, 125 125 http: ReqwestClient, 126 + /// DPoP nonces keyed by endpoint URL, for use_dpop_nonce retry. 127 + dpop_nonces: Mutex<HashMap<Url, String>>, 126 128 } 127 129 128 130 /// Factory trait for building RelyingParty instances. ··· 235 237 clock, 236 238 rng: Mutex::new(rng), 237 239 http, 240 + dpop_nonces: Mutex::new(HashMap::new()), 238 241 } 239 242 } 240 243 ··· 334 337 params.push(("client_assertion", jwt)); 335 338 } 336 339 337 - // Build DPoP proof. 338 - let dpop = self.sign_dpop( 340 + // Build initial DPoP proof (without nonce, initially). 341 + let mut dpop = self.sign_dpop( 339 342 "POST", 340 343 &req.as_descriptor.pushed_authorization_request_endpoint, 341 344 None, ··· 344 347 // POST to PAR endpoint with retry logic for use_dpop_nonce. 345 348 let body = serde_urlencoded::to_string(&params)?; 346 349 let mut attempt = 0; 350 + let htu = req 351 + .as_descriptor 352 + .pushed_authorization_request_endpoint 353 + .clone(); 354 + 347 355 loop { 348 356 let response = self 349 357 .http 350 - .post( 351 - req.as_descriptor 352 - .pushed_authorization_request_endpoint 353 - .clone(), 354 - ) 358 + .post(htu.clone()) 355 359 .header("Content-Type", "application/x-www-form-urlencoded") 356 360 .header("DPoP", dpop.clone()) 357 361 .body(body.clone()) ··· 382 386 383 387 // Handle use_dpop_nonce error on first attempt only. 384 388 let status = response.status().as_u16(); 389 + // Extract nonce from headers before consuming response for body. 390 + let nonce_value = response 391 + .headers() 392 + .get("DPoP-Nonce") 393 + .and_then(|v| v.to_str().ok()) 394 + .map(|s| s.to_string()); 385 395 let body_text = response.text().await.unwrap_or_default(); 396 + 386 397 if status == 400 && attempt == 0 { 387 398 if let Ok(error_response) = serde_json::from_str::<serde_json::Value>(&body_text) { 388 399 if error_response.get("error").and_then(|v| v.as_str()) 389 400 == Some("use_dpop_nonce") 390 401 { 391 - // Retry with nonce. 392 - attempt += 1; 393 - continue; 402 + // If we extracted a nonce from headers, retry with it. 403 + if let Some(nonce) = nonce_value { 404 + // Store the nonce for this endpoint. 405 + self.dpop_nonces.lock().unwrap().insert(htu.clone(), nonce); 406 + 407 + // Re-sign DPoP with the nonce now included. 408 + dpop = self.sign_dpop("POST", &htu, None)?; 409 + 410 + // Retry with new DPoP header. 411 + attempt += 1; 412 + continue; 413 + } 394 414 } 395 415 } 396 416 } ··· 604 624 "htu": htu.as_str(), 605 625 "iat": iat, 606 626 }); 627 + 628 + // Include nonce claim if a nonce is stored for this endpoint. 629 + if let Some(nonce) = self.dpop_nonces.lock().unwrap().get(htu).cloned() { 630 + claims["nonce"] = json!(nonce); 631 + } 607 632 608 633 if let Some(ath_val) = ath { 609 634 claims["ath"] = json!(ath_val);