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 DPoP-bound token tests to get_session (H1)

Two integration tests for the DPoP binding path in GET /xrpc/com.atproto.server.getSession:
- dpop_bound_token_with_valid_proof_returns_200: cnf.jkt bound token + matching ath/sig proof
- dpop_bound_token_without_proof_returns_401: cnf.jkt present but no DPoP header

These test helpers (ath_of, thumbprint_of, make_dpop_proof) are inlined since
auth/mod.rs test helpers are pub(super) and not accessible from routes/.

authored by

Malpercio and committed by
Tangled
12c9e517 aa580523

+160 -9
+160 -9
crates/relay/src/routes/get_session.rs
··· 84 84 85 85 use crate::app::{app, test_state}; 86 86 87 + // ── DPoP test helpers ───────────────────────────────────────────────────── 88 + 89 + /// Compute the base64url-encoded SHA-256 hash of an access token (ath claim value). 90 + fn ath_of(token: &str) -> String { 91 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 92 + use base64::Engine; 93 + use sha2::{Digest, Sha256}; 94 + URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())) 95 + } 96 + 97 + /// Compute the JWK thumbprint for a P-256 signing key. 98 + fn thumbprint_of(key: &p256::ecdsa::SigningKey) -> String { 99 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 100 + use base64::Engine; 101 + use sha2::{Digest, Sha256}; 102 + let vk = key.verifying_key(); 103 + let pt = vk.to_encoded_point(false); 104 + let x = URL_SAFE_NO_PAD.encode(pt.x().unwrap()); 105 + let y = URL_SAFE_NO_PAD.encode(pt.y().unwrap()); 106 + let jwk_canon = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#); 107 + URL_SAFE_NO_PAD.encode(Sha256::digest(jwk_canon.as_bytes())) 108 + } 109 + 110 + /// Build a minimal DPoP proof JWT for the given key, method, URL, and access token. 111 + fn make_dpop_proof( 112 + key: &p256::ecdsa::SigningKey, 113 + htm: &str, 114 + htu: &str, 115 + access_token: &str, 116 + ) -> String { 117 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 118 + use base64::Engine; 119 + use p256::ecdsa::signature::Signer; 120 + 121 + let vk = key.verifying_key(); 122 + let pt = vk.to_encoded_point(false); 123 + let x = URL_SAFE_NO_PAD.encode(pt.x().unwrap()); 124 + let y = URL_SAFE_NO_PAD.encode(pt.y().unwrap()); 125 + let jwk = serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y }); 126 + 127 + let now = std::time::SystemTime::now() 128 + .duration_since(std::time::UNIX_EPOCH) 129 + .unwrap() 130 + .as_secs(); 131 + 132 + let header = serde_json::json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": jwk }); 133 + let payload = serde_json::json!({ 134 + "htm": htm, 135 + "htu": htu, 136 + "iat": now, 137 + "jti": uuid::Uuid::new_v4().to_string(), 138 + "ath": ath_of(access_token), 139 + }); 140 + 141 + let hdr = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 142 + let pay = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 143 + let signing_input = format!("{hdr}.{pay}"); 144 + let sig: p256::ecdsa::Signature = key.sign(signing_input.as_bytes()); 145 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8]); 146 + format!("{hdr}.{pay}.{sig_b64}") 147 + } 148 + 87 149 // ── Helpers ─────────────────────────────────────────────────────────────── 88 150 89 151 /// Issue a valid HS256 access JWT for a DID using the test state's fixed secret. ··· 485 547 } 486 548 487 549 // ── DPoP binding tests ───────────────────────────────────────────────────── 488 - // Note: Complete DPoP test coverage requires: 489 - // - ES256 token minting with cnf.jkt binding 490 - // - DPoP proof creation with ath claim matching the token 491 - // - Validation of ath (access token hash) claim in the DPoP proof 492 - // - Validation of cnf.jkt (key binding) match between token and proof 493 - // 494 - // These tests are deferred to a dedicated DPoP test module that can leverage 495 - // the test helpers in auth/mod.rs. Current coverage: DPoP extraction and 496 - // validation is exercised indirectly through oauth_token tests. 550 + 551 + #[tokio::test] 552 + async fn dpop_bound_token_with_valid_proof_returns_200() { 553 + // A DPoP-bound access token (cnf.jkt present) WITH a valid DPoP proof returns 200. 554 + // Exercises the full DPoP validation path: jkt binding + ath claim + signature. 555 + let state = test_state().await; 556 + insert_account( 557 + &state.db, 558 + "did:plc:dpop", 559 + "dpop.test.example.com", 560 + "dpop@example.com", 561 + ) 562 + .await; 563 + 564 + let dpop_key = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); 565 + let jkt = thumbprint_of(&dpop_key); 566 + 567 + let now = std::time::SystemTime::now() 568 + .duration_since(std::time::UNIX_EPOCH) 569 + .unwrap() 570 + .as_secs(); 571 + let token = { 572 + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; 573 + encode( 574 + &Header::new(Algorithm::HS256), 575 + &serde_json::json!({ 576 + "scope": "com.atproto.access", 577 + "sub": "did:plc:dpop", 578 + "iat": now, 579 + "exp": now + 7200_u64, 580 + "cnf": { "jkt": jkt }, 581 + }), 582 + &EncodingKey::from_secret(&state.jwt_secret), 583 + ) 584 + .unwrap() 585 + }; 586 + 587 + let public_url = &state.config.public_url; 588 + let htu = format!("{public_url}/xrpc/com.atproto.server.getSession"); 589 + let proof = make_dpop_proof(&dpop_key, "GET", &htu, &token); 590 + 591 + let response = app(state) 592 + .oneshot( 593 + Request::builder() 594 + .method("GET") 595 + .uri("/xrpc/com.atproto.server.getSession") 596 + .header("Authorization", format!("Bearer {token}")) 597 + .header("DPoP", proof) 598 + .body(Body::empty()) 599 + .unwrap(), 600 + ) 601 + .await 602 + .unwrap(); 603 + 604 + assert_eq!(response.status(), StatusCode::OK); 605 + let json = body_json(response).await; 606 + assert_eq!(json["did"], "did:plc:dpop"); 607 + } 608 + 609 + #[tokio::test] 610 + async fn dpop_bound_token_without_proof_returns_401() { 611 + // A DPoP-bound access token (cnf.jkt present) WITHOUT a DPoP header must be 612 + // rejected — RFC 9449 §7.1: cnf.jkt requires a DPoP proof to prevent downgrade. 613 + let state = test_state().await; 614 + 615 + let dpop_key = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng); 616 + let jkt = thumbprint_of(&dpop_key); 617 + 618 + let now = std::time::SystemTime::now() 619 + .duration_since(std::time::UNIX_EPOCH) 620 + .unwrap() 621 + .as_secs(); 622 + let token = { 623 + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; 624 + encode( 625 + &Header::new(Algorithm::HS256), 626 + &serde_json::json!({ 627 + "scope": "com.atproto.access", 628 + "sub": "did:plc:dpop", 629 + "iat": now, 630 + "exp": now + 7200_u64, 631 + "cnf": { "jkt": jkt }, 632 + }), 633 + &EncodingKey::from_secret(&state.jwt_secret), 634 + ) 635 + .unwrap() 636 + }; 637 + 638 + // No DPoP header — should be rejected. 639 + let response = app(state) 640 + .oneshot(get_session_request(&token)) 641 + .await 642 + .unwrap(); 643 + 644 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 645 + let json = body_json(response).await; 646 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 647 + } 497 648 }