A better Rust ATProto crate

optional service auth

Orual d5d29a33 5bd87b46

Changed files
+131
crates
jacquard-axum
+131
crates/jacquard-axum/src/service_auth.rs
··· 242 242 /// ``` 243 243 pub struct ExtractServiceAuth(pub VerifiedServiceAuth<'static>); 244 244 245 + /// Axum extractor for optional service authentication. 246 + /// 247 + /// Like `ExtractServiceAuth`, but returns `None` if no Authorization header 248 + /// is present. If a header IS present but invalid, returns an error. 249 + /// 250 + /// Use this for endpoints that work for both authenticated and anonymous users, 251 + /// but show different content based on auth status. 252 + /// 253 + /// # Example 254 + /// 255 + /// ```no_run 256 + /// use axum::{Router, routing::get}; 257 + /// use jacquard_axum::service_auth::{ServiceAuthConfig, ExtractOptionalServiceAuth}; 258 + /// use jacquard_identity::JacquardResolver; 259 + /// use jacquard_identity::resolver::ResolverOptions; 260 + /// use jacquard_common::types::string::Did; 261 + /// 262 + /// async fn handler( 263 + /// ExtractOptionalServiceAuth(auth): ExtractOptionalServiceAuth, 264 + /// ) -> String { 265 + /// match auth { 266 + /// Some(a) => format!("Authenticated as {}", a.did()), 267 + /// None => "Anonymous request".to_string(), 268 + /// } 269 + /// } 270 + /// 271 + /// #[tokio::main] 272 + /// async fn main() { 273 + /// let resolver = JacquardResolver::new( 274 + /// reqwest::Client::new(), 275 + /// ResolverOptions::default(), 276 + /// ); 277 + /// let config = ServiceAuthConfig::new( 278 + /// Did::new_static("did:web:example.com").unwrap(), 279 + /// resolver, 280 + /// ); 281 + /// 282 + /// let app = Router::new() 283 + /// .route("/xrpc/com.example.getData", get(handler)) 284 + /// .with_state(config); 285 + /// 286 + /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") 287 + /// .await 288 + /// .unwrap(); 289 + /// axum::serve(listener, app).await.unwrap(); 290 + /// } 291 + /// ``` 292 + pub struct ExtractOptionalServiceAuth(pub Option<VerifiedServiceAuth<'static>>); 293 + 245 294 /// Errors that can occur during service auth verification. 246 295 #[derive(Debug, Error, miette::Diagnostic)] 247 296 pub enum ServiceAuthError { ··· 409 458 lxm: claims.lxm.as_ref().map(|l| l.clone().into_static()), 410 459 jti: claims.jti.as_ref().map(|j| j.clone().into_static()), 411 460 })) 461 + } 462 + } 463 + } 464 + 465 + impl<S> FromRequestParts<S> for ExtractOptionalServiceAuth 466 + where 467 + S: ServiceAuth + Send + Sync, 468 + S::Resolver: Send + Sync, 469 + { 470 + type Rejection = ServiceAuthError; 471 + 472 + fn from_request_parts( 473 + parts: &mut Parts, 474 + state: &S, 475 + ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send { 476 + async move { 477 + // Check for Authorization header - if missing, return None (not an error) 478 + let auth_header = match parts.headers.get(header::AUTHORIZATION) { 479 + Some(h) => h, 480 + None => return Ok(ExtractOptionalServiceAuth(None)), 481 + }; 482 + 483 + // Header is present - now we MUST validate it (bad auth = error) 484 + let auth_str = auth_header 485 + .to_str() 486 + .map_err(|_| ServiceAuthError::InvalidAuthHeader)?; 487 + 488 + let token = auth_str 489 + .strip_prefix("Bearer ") 490 + .ok_or(ServiceAuthError::InvalidAuthHeader)?; 491 + 492 + // Parse JWT 493 + let parsed = service_auth::parse_jwt(token)?; 494 + 495 + // Get claims for DID resolution 496 + let claims = parsed.claims(); 497 + 498 + // Resolve DID to get signing key 499 + let did_doc = state 500 + .resolver() 501 + .resolve_did_doc(&claims.iss) 502 + .await 503 + .map_err(|e| ServiceAuthError::DidResolutionFailed { 504 + did: claims.iss.clone().into_static(), 505 + source: Box::new(e), 506 + })?; 507 + 508 + // Parse the DID document response to get verification methods 509 + let doc = did_doc 510 + .parse() 511 + .map_err(|e| ServiceAuthError::DidResolutionFailed { 512 + did: claims.iss.clone().into_static(), 513 + source: Box::new(e), 514 + })?; 515 + 516 + // Extract signing key from DID document 517 + let verification_methods = doc 518 + .verification_method 519 + .as_deref() 520 + .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; 521 + 522 + let signing_key = extract_signing_key(verification_methods) 523 + .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; 524 + 525 + // Verify signature FIRST - if this fails, nothing else matters 526 + service_auth::verify_signature(&parsed, &signing_key)?; 527 + 528 + // Now validate claims (audience, expiration, etc.) 529 + claims.validate(state.service_did())?; 530 + 531 + // Check method binding if required 532 + if state.require_lxm() && claims.lxm.is_none() { 533 + return Err(ServiceAuthError::MethodBindingRequired); 534 + } 535 + 536 + // All checks passed - return verified auth 537 + Ok(ExtractOptionalServiceAuth(Some(VerifiedServiceAuth { 538 + did: claims.iss.clone().into_static(), 539 + aud: claims.aud.clone().into_static(), 540 + lxm: claims.lxm.as_ref().map(|l| l.clone().into_static()), 541 + jti: claims.jti.as_ref().map(|j| j.clone().into_static()), 542 + }))) 412 543 } 413 544 } 414 545 }