A better Rust ATProto crate
1use axum::{ 2 Extension, Router, 3 body::Body, 4 extract::Request, 5 http::{StatusCode, header}, 6 middleware, 7 routing::get, 8}; 9use base64::Engine; 10use base64::engine::general_purpose::URL_SAFE_NO_PAD; 11use bytes::Bytes; 12use jacquard_axum::service_auth::{ 13 ExtractServiceAuth, ServiceAuthConfig, VerifiedServiceAuth, service_auth_middleware, 14}; 15use jacquard_common::{ 16 CowStr, IntoStatic, 17 service_auth::JwtHeader, 18 types::{ 19 did::Did, 20 did_doc::{DidDocument, VerificationMethod}, 21 }, 22}; 23use jacquard_identity::resolver::{ 24 DidDocResponse, IdentityError, IdentityResolver, ResolverOptions, 25}; 26use reqwest::StatusCode as ReqwestStatusCode; 27use serde_json::json; 28use std::future::Future; 29use tower::ServiceExt; 30 31// Test helper: create a signed JWT 32fn create_test_jwt( 33 iss: &str, 34 aud: &str, 35 exp: i64, 36 lxm: Option<&str>, 37 signing_key: &k256::ecdsa::SigningKey, 38) -> String { 39 use k256::ecdsa::signature::Signer; 40 41 let header = JwtHeader { 42 alg: CowStr::new_static("ES256K"), 43 typ: CowStr::new_static("JWT"), 44 }; 45 46 let mut claims_json = json!({ 47 "iss": iss, 48 "aud": aud, 49 "exp": exp, 50 "iat": chrono::Utc::now().timestamp(), 51 }); 52 53 if let Some(lxm_val) = lxm { 54 claims_json["lxm"] = json!(lxm_val); 55 } 56 57 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 58 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims_json).unwrap()); 59 60 let signing_input = format!("{}.{}", header_b64, payload_b64); 61 62 let signature: k256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 63 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 64 65 format!("{}.{}", signing_input, signature_b64) 66} 67 68// Test helper: create DID document with k256 key 69fn create_test_did_doc(did: &str, public_key: &k256::ecdsa::VerifyingKey) -> DidDocument<'static> { 70 use std::collections::BTreeMap; 71 72 // Encode as compressed SEC1 73 let encoded_point = public_key.to_encoded_point(true); 74 let key_bytes = encoded_point.as_bytes(); 75 76 // Multicodec prefix for secp256k1-pub (0xe701) 77 let mut multicodec_bytes = vec![0xe7, 0x01]; 78 multicodec_bytes.extend_from_slice(key_bytes); 79 80 // Multibase encode (base58btc = 'z') 81 let multibase_key = multibase::encode(multibase::Base::Base58Btc, &multicodec_bytes); 82 83 DidDocument { 84 id: Did::new_owned(did).unwrap().into_static(), 85 also_known_as: None, 86 verification_method: Some(vec![VerificationMethod { 87 id: CowStr::Owned(format!("{}#atproto", did).into()), 88 r#type: CowStr::new_static("Multikey"), 89 controller: Some(CowStr::Owned(did.into())), 90 public_key_multibase: Some(CowStr::Owned(multibase_key.into())), 91 extra_data: BTreeMap::new(), 92 }]), 93 service: None, 94 extra_data: BTreeMap::new(), 95 } 96} 97 98// Mock resolver for tests 99#[derive(Clone)] 100struct MockResolver { 101 did_doc: DidDocument<'static>, 102 options: ResolverOptions, 103} 104 105impl MockResolver { 106 fn new(did_doc: DidDocument<'static>) -> Self { 107 Self { 108 did_doc, 109 options: ResolverOptions::default(), 110 } 111 } 112} 113 114impl IdentityResolver for MockResolver { 115 fn options(&self) -> &ResolverOptions { 116 &self.options 117 } 118 119 fn resolve_handle( 120 &self, 121 _handle: &jacquard_common::types::string::Handle<'_>, 122 ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send { 123 async { Err(IdentityError::InvalidWellKnown) } 124 } 125 126 fn resolve_did_doc( 127 &self, 128 _did: &Did<'_>, 129 ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send { 130 let doc = self.did_doc.clone(); 131 async move { 132 let json = serde_json::to_vec(&doc).unwrap(); 133 Ok(DidDocResponse { 134 buffer: Bytes::from(json), 135 status: ReqwestStatusCode::OK, 136 requested: Some(doc.id.clone()), 137 }) 138 } 139 } 140} 141 142#[tokio::test] 143async fn test_extractor_with_valid_jwt() { 144 // Generate keypair 145 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 146 let verifying_key = signing_key.verifying_key(); 147 148 // Create test DID and JWT 149 let user_did = "did:plc:test123"; 150 let service_did = "did:web:feedgen.example.com"; 151 let exp = chrono::Utc::now().timestamp() + 300; 152 153 // JWT with lxm 154 let jwt = create_test_jwt( 155 user_did, 156 service_did, 157 exp, 158 Some("app.bsky.feed.getFeedSkeleton"), 159 &signing_key, 160 ); 161 162 // Create mock resolver 163 let did_doc = create_test_did_doc(user_did, verifying_key); 164 let resolver = MockResolver::new(did_doc); 165 166 // Create config (default: require_lxm = true) 167 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 168 169 // Create handler 170 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 171 format!("Authenticated as {}", auth.did()) 172 } 173 174 let app = Router::new() 175 .route("/test", get(handler)) 176 .with_state(config); 177 178 // Create request with JWT 179 let request = Request::builder() 180 .uri("/test") 181 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 182 .body(Body::empty()) 183 .unwrap(); 184 185 // Send request 186 let response = app.oneshot(request).await.unwrap(); 187 188 assert_eq!(response.status(), StatusCode::OK); 189 190 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 191 .await 192 .unwrap(); 193 let body = String::from_utf8(body_bytes.to_vec()).unwrap(); 194 195 assert_eq!(body, format!("Authenticated as {}", user_did)); 196} 197 198#[tokio::test] 199async fn test_extractor_with_expired_jwt() { 200 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 201 let verifying_key = signing_key.verifying_key(); 202 203 let user_did = "did:plc:test123"; 204 let service_did = "did:web:feedgen.example.com"; 205 let exp = chrono::Utc::now().timestamp() - 300; // Expired 206 207 let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key); 208 209 let did_doc = create_test_did_doc(user_did, verifying_key); 210 let resolver = MockResolver::new(did_doc); 211 212 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 213 214 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 215 format!("Authenticated as {}", auth.did()) 216 } 217 218 let app = Router::new() 219 .route("/test", get(handler)) 220 .with_state(config); 221 222 let request = Request::builder() 223 .uri("/test") 224 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 225 .body(Body::empty()) 226 .unwrap(); 227 228 let response = app.oneshot(request).await.unwrap(); 229 230 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 231} 232 233#[tokio::test] 234async fn test_extractor_with_wrong_audience() { 235 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 236 let verifying_key = signing_key.verifying_key(); 237 238 let user_did = "did:plc:test123"; 239 let service_did = "did:web:feedgen.example.com"; 240 let wrong_aud = "did:web:other.example.com"; 241 let exp = chrono::Utc::now().timestamp() + 300; 242 243 let jwt = create_test_jwt(user_did, wrong_aud, exp, None, &signing_key); 244 245 let did_doc = create_test_did_doc(user_did, verifying_key); 246 let resolver = MockResolver::new(did_doc); 247 248 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 249 250 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 251 format!("Authenticated as {}", auth.did()) 252 } 253 254 let app = Router::new() 255 .route("/test", get(handler)) 256 .with_state(config); 257 258 let request = Request::builder() 259 .uri("/test") 260 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 261 .body(Body::empty()) 262 .unwrap(); 263 264 let response = app.oneshot(request).await.unwrap(); 265 266 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 267} 268 269#[tokio::test] 270async fn test_extractor_missing_auth_header() { 271 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 272 let verifying_key = signing_key.verifying_key(); 273 274 let user_did = "did:plc:test123"; 275 let service_did = "did:web:feedgen.example.com"; 276 277 let did_doc = create_test_did_doc(user_did, verifying_key); 278 let resolver = MockResolver::new(did_doc); 279 280 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 281 282 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 283 format!("Authenticated as {}", auth.did()) 284 } 285 286 let app = Router::new() 287 .route("/test", get(handler)) 288 .with_state(config); 289 290 let request = Request::builder().uri("/test").body(Body::empty()).unwrap(); 291 292 let response = app.oneshot(request).await.unwrap(); 293 294 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 295} 296 297#[tokio::test] 298async fn test_middleware_with_valid_jwt() { 299 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 300 let verifying_key = signing_key.verifying_key(); 301 302 let user_did = "did:plc:test123"; 303 let service_did = "did:web:feedgen.example.com"; 304 let exp = chrono::Utc::now().timestamp() + 300; 305 306 // JWT with lxm 307 let jwt = create_test_jwt( 308 user_did, 309 service_did, 310 exp, 311 Some("app.bsky.feed.getFeedSkeleton"), 312 &signing_key, 313 ); 314 315 let did_doc = create_test_did_doc(user_did, verifying_key); 316 let resolver = MockResolver::new(did_doc); 317 318 // Create config (default: require_lxm = true) 319 let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 320 321 async fn handler(Extension(auth): Extension<VerifiedServiceAuth<'static>>) -> String { 322 format!("Authenticated as {}", auth.did()) 323 } 324 325 let app = Router::new() 326 .route("/test", get(handler)) 327 .layer(middleware::from_fn_with_state( 328 config.clone(), 329 service_auth_middleware::<ServiceAuthConfig<MockResolver>>, 330 )) 331 .with_state(config); 332 333 let request = Request::builder() 334 .uri("/test") 335 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 336 .body(Body::empty()) 337 .unwrap(); 338 339 let response = app.oneshot(request).await.unwrap(); 340 341 assert_eq!(response.status(), StatusCode::OK); 342 343 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 344 .await 345 .unwrap(); 346 let body = String::from_utf8(body_bytes.to_vec()).unwrap(); 347 348 assert_eq!(body, format!("Authenticated as {}", user_did)); 349} 350 351#[tokio::test] 352async fn test_require_lxm() { 353 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 354 let verifying_key = signing_key.verifying_key(); 355 356 let user_did = "did:plc:test123"; 357 let service_did = "did:web:feedgen.example.com"; 358 let exp = chrono::Utc::now().timestamp() + 300; 359 360 // JWT without lxm 361 let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key); 362 363 let did_doc = create_test_did_doc(user_did, verifying_key); 364 let resolver = MockResolver::new(did_doc); 365 366 let config = 367 ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true); 368 369 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 370 format!("Authenticated as {}", auth.did()) 371 } 372 373 let app = Router::new() 374 .route("/test", get(handler)) 375 .with_state(config); 376 377 let request = Request::builder() 378 .uri("/test") 379 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 380 .body(Body::empty()) 381 .unwrap(); 382 383 let response = app.oneshot(request).await.unwrap(); 384 385 // Should fail because lxm is required but missing 386 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 387} 388 389#[tokio::test] 390async fn test_with_lxm_present() { 391 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 392 let verifying_key = signing_key.verifying_key(); 393 394 let user_did = "did:plc:test123"; 395 let service_did = "did:web:feedgen.example.com"; 396 let exp = chrono::Utc::now().timestamp() + 300; 397 398 // JWT with lxm 399 let jwt = create_test_jwt( 400 user_did, 401 service_did, 402 exp, 403 Some("app.bsky.feed.getFeedSkeleton"), 404 &signing_key, 405 ); 406 407 let did_doc = create_test_did_doc(user_did, verifying_key); 408 let resolver = MockResolver::new(did_doc); 409 410 let config = 411 ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true); 412 413 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 414 format!( 415 "Authenticated as {} for {}", 416 auth.did(), 417 auth.lxm().unwrap() 418 ) 419 } 420 421 let app = Router::new() 422 .route("/test", get(handler)) 423 .with_state(config); 424 425 let request = Request::builder() 426 .uri("/test") 427 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 428 .body(Body::empty()) 429 .unwrap(); 430 431 let response = app.oneshot(request).await.unwrap(); 432 433 assert_eq!(response.status(), StatusCode::OK); 434 435 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 436 .await 437 .unwrap(); 438 let body = String::from_utf8(body_bytes.to_vec()).unwrap(); 439 440 assert_eq!( 441 body, 442 format!( 443 "Authenticated as {} for app.bsky.feed.getFeedSkeleton", 444 user_did 445 ) 446 ); 447} 448 449#[tokio::test] 450async fn test_legacy_without_lxm() { 451 let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); 452 let verifying_key = signing_key.verifying_key(); 453 454 let user_did = "did:plc:test123"; 455 let service_did = "did:web:feedgen.example.com"; 456 let exp = chrono::Utc::now().timestamp() + 300; 457 458 // JWT without lxm 459 let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key); 460 461 let did_doc = create_test_did_doc(user_did, verifying_key); 462 let resolver = MockResolver::new(did_doc); 463 464 // Legacy config: lxm not required 465 let config = 466 ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(false); 467 468 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 469 format!("Authenticated as {}", auth.did()) 470 } 471 472 let app = Router::new() 473 .route("/test", get(handler)) 474 .with_state(config); 475 476 let request = Request::builder() 477 .uri("/test") 478 .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) 479 .body(Body::empty()) 480 .unwrap(); 481 482 let response = app.oneshot(request).await.unwrap(); 483 484 // Should succeed because lxm is not required 485 assert_eq!(response.status(), StatusCode::OK); 486 487 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 488 .await 489 .unwrap(); 490 let body = String::from_utf8(body_bytes.to_vec()).unwrap(); 491 492 assert_eq!(body, format!("Authenticated as {}", user_did)); 493} 494 495#[tokio::test] 496async fn test_invalid_signature() { 497 // Real JWT token from did:plc:uc7pehijmk5jrllip4cglxdd with bogus signature 498 let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NjAzOTMyMzUsImlzcyI6ImRpZDpwbGM6dWM3cGVoaWptazVqcmxsaXA0Y2dseGRkIiwiYXVkIjoiZGlkOndlYjpkZXYucGRzbW9vdmVyLmNvbSIsImV4cCI6MTc2MDM5MzI5NSwibHhtIjoiY29tLnBkc21vb3Zlci5iYWNrdXAuc2lnblVwIiwianRpIjoiMTk0MDQzMzQyNmMyNTNlZjhmNmYxZDJjZWE1YzI0NGMifQ.h5BrgYE"; 499 500 // Real DID document for did:plc:uc7pehijmk5jrllip4cglxdd 501 let did_doc_json = r##"{ 502 "id": "did:plc:uc7pehijmk5jrllip4cglxdd", 503 "alsoKnownAs": ["at://bailey.skeetcentral.com"], 504 "verificationMethod": [{ 505 "controller": "did:plc:uc7pehijmk5jrllip4cglxdd", 506 "id": "did:plc:uc7pehijmk5jrllip4cglxdd#atproto", 507 "publicKeyMultibase": "zQ3shNBS3N4EB3vX5G1HoxFkS8tDLFXUHaV85rHQZgVM88rM5", 508 "type": "Multikey" 509 }], 510 "service": [{ 511 "id": "#atproto_pds", 512 "serviceEndpoint": "https://skeetcentral.com", 513 "type": "AtprotoPersonalDataServer" 514 }] 515 }"##; 516 517 let did_doc: DidDocument = serde_json::from_str(did_doc_json).unwrap(); 518 let resolver = MockResolver::new(did_doc); 519 520 let config = ServiceAuthConfig::new( 521 Did::new_static("did:web:dev.pdsmoover.com").unwrap(), 522 resolver, 523 ); 524 525 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 526 format!("Authenticated as {}", auth.did()) 527 } 528 529 let app = Router::new() 530 .route("/test", get(handler)) 531 .with_state(config); 532 533 let request = Request::builder() 534 .uri("/test") 535 .header(header::AUTHORIZATION, format!("Bearer {}", token)) 536 .body(Body::empty()) 537 .unwrap(); 538 539 let response = app.oneshot(request).await.unwrap(); 540 541 // Should fail due to invalid signature 542 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 543}