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.

test: add wire-level DPoP proof verification to nonce_retry and refresh_dpop tests (H7, H8)

Uses httpmock 0.7's when.matches(fn_ptr) with custom predicates that decode the DPoP proof
JWT from each request header to verify at the wire level:
- refresh_dpop_proof_has_no_ath_claim: mock only serves 200 when proof has no ath claim
- nonce_retry_sends_exactly_two_requests: two-mock FIFO strategy proves the retry carries
the nonce (first mock rejects proofs without nonce; retry hits the second success mock)

Also adds decode_dpop_payload, dpop_has_no_ath, dpop_has_no_nonce helper predicates.

authored by

Malpercio and committed by
Tangled
c28abc66 12c9e517

+65 -14
+65 -14
apps/identity-wallet/src-tauri/src/oauth_client.rs
··· 363 363 }) 364 364 } 365 365 366 + /// Decode a DPoP proof JWT from the request's DPoP header and return the payload. 367 + /// Returns None if the header is absent or malformed. 368 + fn decode_dpop_payload(req: &HttpMockRequest) -> Option<serde_json::Value> { 369 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 370 + use base64::Engine; 371 + let val = req 372 + .headers 373 + .as_ref()? 374 + .iter() 375 + .find(|(k, _)| k.eq_ignore_ascii_case("dpop")) 376 + .map(|(_, v)| v.as_str())?; 377 + let parts: Vec<&str> = val.split('.').collect(); 378 + let payload_bytes = URL_SAFE_NO_PAD.decode(parts.get(1)?).ok()?; 379 + serde_json::from_slice(&payload_bytes).ok() 380 + } 381 + 382 + /// `when.matches()` predicate: DPoP proof must NOT contain an `ath` claim. 383 + /// Used for refresh-grant requests where no access token is available yet. 384 + fn dpop_has_no_ath(req: &HttpMockRequest) -> bool { 385 + decode_dpop_payload(req) 386 + .map(|p| p.get("ath").is_none()) 387 + .unwrap_or(false) 388 + } 389 + 390 + /// `when.matches()` predicate: DPoP proof must NOT contain a `nonce` claim. 391 + /// Used to match the first (pre-challenge) request in a nonce-retry scenario. 392 + fn dpop_has_no_nonce(req: &HttpMockRequest) -> bool { 393 + decode_dpop_payload(req) 394 + .map(|p| p.get("nonce").is_none()) 395 + .unwrap_or(false) 396 + } 397 + 366 398 #[tokio::test] 367 399 async fn dpop_and_authorization_headers_present_on_get() { 368 400 // Verifies: Every request carries Authorization: DPoP {token} and DPoP: {proof} ··· 387 419 #[tokio::test] 388 420 async fn nonce_retry_sends_exactly_two_requests() { 389 421 // use_dpop_nonce 400 triggers exactly one retry; the retry carries the server nonce. 390 - // The single mock always returns 400+nonce; the retry response is returned as-is. 422 + // Wire-level verification via two mocks (httpmock FIFO: first registered wins): 423 + // Mock1 (specific): first request has NO nonce in DPoP proof → 400+DPoP-Nonce 424 + // Mock2 (general): retry has nonce in proof → Mock1 won't match → Mock2 serves 425 + // 426 + // If the retry proof omits the nonce, Mock1 matches again → Mock1.hits() would 427 + // be 2 and Mock2.hits() would be 0, causing the assertion below to fail. 391 428 let server = MockServer::start(); 392 429 393 - let mock = server.mock(|when, then| { 394 - when.method(GET).path("/resource"); 430 + let mock_challenge = server.mock(|when, then| { 431 + when.method(GET) 432 + .path("/resource") 433 + .matches(dpop_has_no_nonce); 395 434 then.status(400) 396 435 .header("DPoP-Nonce", "test-server-nonce") 397 436 .json_body(serde_json::json!({"error": "use_dpop_nonce"})); 398 437 }); 438 + let mock_retry = server.mock(|when, then| { 439 + when.method(GET).path("/resource"); 440 + then.status(200).body("ok"); 441 + }); 399 442 400 443 let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 401 444 let session = make_session("my_access_token", "my_refresh_token", 300); 402 445 let client = OAuthClient::new_for_test(keypair, session.clone(), server.base_url()); 403 446 404 - // The retry response (400) is returned — no panic, no infinite loop. 405 447 let resp = client 406 448 .get("/resource") 407 449 .await 408 450 .expect("must not error on retry path"); 409 - assert_eq!(resp.status().as_u16(), 400); 451 + assert_eq!( 452 + resp.status().as_u16(), 453 + 200, 454 + "retry must succeed with the nonce" 455 + ); 410 456 411 - // Exactly 2 requests: original attempt + one retry. 412 457 assert_eq!( 413 - mock.hits(), 414 - 2, 415 - "must make exactly 2 requests: attempt + one retry" 458 + mock_challenge.hits(), 459 + 1, 460 + "initial request must hit the nonce-challenge mock" 461 + ); 462 + assert_eq!( 463 + mock_retry.hits(), 464 + 1, 465 + "retry must hit the success mock (nonce in proof)" 416 466 ); 417 467 418 468 // The server-provided nonce must be stored in session after receiving a nonce challenge. ··· 479 529 480 530 #[tokio::test] 481 531 async fn refresh_dpop_proof_has_no_ath_claim() { 482 - // Verifies: Refresh grant DPoP proof must not include ath (no access token in hand) 532 + // Verifies: Refresh grant DPoP proof must not include ath (RFC 9449 §4.3). 533 + // Wire-level check: mock only responds 200 if the DPoP header has NO ath claim. 534 + // If refresh_token() sends a proof with ath, the mock won't match → test fails. 483 535 let server = MockServer::start(); 484 536 485 537 server.mock(|when, then| { 486 - when.method(POST).path("/oauth/token"); 538 + when.method(POST) 539 + .path("/oauth/token") 540 + .matches(dpop_has_no_ath); 487 541 then.status(200).json_body(token_response_body()); 488 542 }); 489 543 490 544 let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 491 - // Session near expiry to trigger refresh. 492 545 let session = make_session("old_token", "my_refresh_token", 30); 493 546 let client = OAuthClient::new_for_test(keypair, session, server.base_url()); 494 547 495 - // This test verifies refresh succeeds; the absence of ath is verified 496 - // by the make_proof method which omits ath when ath_opt is None. 497 548 client.refresh_token().await.expect("refresh must succeed"); 498 549 } 499 550