A better Rust ATProto crate
102
fork

Configure Feed

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

at pretty-codegen 954 lines 34 kB view raw
1#[cfg(not(target_arch = "wasm32"))] 2use std::future::Future; 3 4use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 5use http::{Request, StatusCode}; 6use jacquard_common::CowStr; 7use jacquard_common::IntoStatic; 8#[allow(unused_imports)] 9use jacquard_common::cowstr::ToCowStr; 10use jacquard_common::deps::fluent_uri::Uri; 11use jacquard_common::types::did_doc::DidDocument; 12use jacquard_common::types::ident::AtIdentifier; 13use jacquard_common::{http_client::HttpClient, types::did::Did}; 14use jacquard_identity::resolver::{IdentityError, IdentityResolver}; 15use smol_str::SmolStr; 16 17/// Convenience alias for a heap-allocated, thread-safe, `'static` error value. 18pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 19 20/// OAuth resolver error for identity and metadata resolution 21#[derive(Debug, thiserror::Error, miette::Diagnostic)] 22#[error("{kind}")] 23pub struct ResolverError { 24 #[diagnostic_source] 25 kind: ResolverErrorKind, 26 #[source] 27 source: Option<BoxError>, 28 #[help] 29 help: Option<SmolStr>, 30 context: Option<SmolStr>, 31 url: Option<SmolStr>, 32 details: Option<SmolStr>, 33 location: Option<SmolStr>, 34} 35 36/// Error categories for OAuth resolver operations 37#[derive(Debug, thiserror::Error, miette::Diagnostic)] 38#[non_exhaustive] 39pub enum ResolverErrorKind { 40 /// Resource not found 41 #[error("resource not found")] 42 #[diagnostic( 43 code(jacquard_oauth::resolver::not_found), 44 help("check the base URL or identifier") 45 )] 46 NotFound, 47 48 /// Invalid AT identifier 49 #[error("invalid at identifier: {0}")] 50 #[diagnostic( 51 code(jacquard_oauth::resolver::at_identifier), 52 help("ensure a valid handle or DID was provided") 53 )] 54 AtIdentifier(SmolStr), 55 56 /// Invalid DID 57 #[error("invalid did: {0}")] 58 #[diagnostic( 59 code(jacquard_oauth::resolver::did), 60 help("ensure DID is correctly formed (did:plc or did:web)") 61 )] 62 Did(SmolStr), 63 64 /// Invalid DID document 65 #[error("invalid did document: {0}")] 66 #[diagnostic( 67 code(jacquard_oauth::resolver::did_document), 68 help("verify the DID document structure and service entries") 69 )] 70 DidDocument(SmolStr), 71 72 /// Protected resource metadata is invalid 73 #[error("protected resource metadata is invalid: {0}")] 74 #[diagnostic( 75 code(jacquard_oauth::resolver::protected_resource_metadata), 76 help("PDS must advertise an authorization server in its protected resource metadata") 77 )] 78 ProtectedResourceMetadata(SmolStr), 79 80 /// Authorization server metadata is invalid 81 #[error("authorization server metadata is invalid: {0}")] 82 #[diagnostic( 83 code(jacquard_oauth::resolver::authorization_server_metadata), 84 help("issuer must match and include the PDS resource") 85 )] 86 AuthorizationServerMetadata(SmolStr), 87 88 /// Identity resolution error 89 #[error("error resolving identity")] 90 #[diagnostic(code(jacquard_oauth::resolver::identity))] 91 Identity, 92 93 /// Unsupported DID method 94 #[error("unsupported did method: {0:?}")] 95 #[diagnostic( 96 code(jacquard_oauth::resolver::unsupported_did_method), 97 help("supported DID methods: did:web, did:plc") 98 )] 99 UnsupportedDidMethod(Did<'static>), 100 101 /// HTTP transport error 102 #[error("transport error")] 103 #[diagnostic(code(jacquard_oauth::resolver::transport))] 104 Transport, 105 106 /// HTTP status error 107 #[error("http status: {0}")] 108 #[diagnostic( 109 code(jacquard_oauth::resolver::http_status), 110 help("check well-known paths and server configuration") 111 )] 112 HttpStatus(StatusCode), 113 114 /// JSON serialization error 115 #[error("json error")] 116 #[diagnostic(code(jacquard_oauth::resolver::serde_json))] 117 SerdeJson, 118 119 /// Form serialization error 120 #[error("form serialization error")] 121 #[diagnostic(code(jacquard_oauth::resolver::serde_form))] 122 SerdeHtmlForm, 123 124 /// URL parsing error 125 #[error("url parsing error")] 126 #[diagnostic(code(jacquard_oauth::resolver::url))] 127 Uri, 128} 129 130impl ResolverError { 131 /// Create a new error with the given kind and optional source 132 pub fn new(kind: ResolverErrorKind, source: Option<BoxError>) -> Self { 133 Self { 134 kind, 135 source, 136 help: None, 137 context: None, 138 url: None, 139 details: None, 140 location: None, 141 } 142 } 143 144 /// Get the error kind 145 pub fn kind(&self) -> &ResolverErrorKind { 146 &self.kind 147 } 148 149 /// Get the source error if present 150 pub fn source_err(&self) -> Option<&BoxError> { 151 self.source.as_ref() 152 } 153 154 /// Get the context string if present 155 pub fn context(&self) -> Option<&str> { 156 self.context.as_ref().map(|s| s.as_str()) 157 } 158 159 /// Get the URL if present 160 pub fn url(&self) -> Option<&str> { 161 self.url.as_ref().map(|s| s.as_str()) 162 } 163 164 /// Get the details if present 165 pub fn details(&self) -> Option<&str> { 166 self.details.as_ref().map(|s| s.as_str()) 167 } 168 169 /// Get the location if present 170 pub fn location(&self) -> Option<&str> { 171 self.location.as_ref().map(|s| s.as_str()) 172 } 173 174 /// Add help text to this error 175 pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 176 self.help = Some(help.into()); 177 self 178 } 179 180 /// Add context to this error 181 pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 182 self.context = Some(context.into()); 183 self 184 } 185 186 /// Add URL to this error 187 pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 188 self.url = Some(url.into()); 189 self 190 } 191 192 /// Add details to this error 193 pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 194 self.details = Some(details.into()); 195 self 196 } 197 198 /// Add location to this error 199 pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 200 self.location = Some(location.into()); 201 self 202 } 203 204 // Constructors for each kind 205 206 /// Create a not found error 207 pub fn not_found() -> Self { 208 Self::new(ResolverErrorKind::NotFound, None) 209 } 210 211 /// Create an invalid AT identifier error 212 pub fn at_identifier(msg: impl Into<SmolStr>) -> Self { 213 Self::new(ResolverErrorKind::AtIdentifier(msg.into()), None) 214 } 215 216 /// Create an invalid DID error 217 pub fn did(msg: impl Into<SmolStr>) -> Self { 218 Self::new(ResolverErrorKind::Did(msg.into()), None) 219 } 220 221 /// Create an invalid DID document error 222 pub fn did_document(msg: impl Into<SmolStr>) -> Self { 223 Self::new(ResolverErrorKind::DidDocument(msg.into()), None) 224 } 225 226 /// Create a protected resource metadata error 227 pub fn protected_resource_metadata(msg: impl Into<SmolStr>) -> Self { 228 Self::new( 229 ResolverErrorKind::ProtectedResourceMetadata(msg.into()), 230 None, 231 ) 232 } 233 234 /// Create an authorization server metadata error 235 pub fn authorization_server_metadata(msg: impl Into<SmolStr>) -> Self { 236 Self::new( 237 ResolverErrorKind::AuthorizationServerMetadata(msg.into()), 238 None, 239 ) 240 } 241 242 /// Create an identity resolution error 243 pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { 244 Self::new(ResolverErrorKind::Identity, Some(Box::new(source))) 245 } 246 247 /// Create an unsupported DID method error 248 pub fn unsupported_did_method(did: Did<'static>) -> Self { 249 Self::new(ResolverErrorKind::UnsupportedDidMethod(did), None) 250 } 251 252 /// Create a transport error 253 pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 254 Self::new(ResolverErrorKind::Transport, Some(Box::new(source))) 255 } 256 257 /// Create an HTTP status error 258 pub fn http_status(status: StatusCode) -> Self { 259 Self::new(ResolverErrorKind::HttpStatus(status), None) 260 } 261} 262 263/// Result type for resolver operations 264pub type Result<T> = std::result::Result<T, ResolverError>; 265 266// From impls for common error types 267 268impl From<IdentityError> for ResolverError { 269 fn from(e: IdentityError) -> Self { 270 let msg = smol_str::format_smolstr!("{:?}", e); 271 Self::new(ResolverErrorKind::Identity, Some(Box::new(e))) 272 .with_context(msg) 273 .with_help("verify handle/DID is valid and resolver configuration") 274 } 275} 276 277impl From<jacquard_common::error::ClientError> for ResolverError { 278 fn from(e: jacquard_common::error::ClientError) -> Self { 279 let msg = smol_str::format_smolstr!("{:?}", e); 280 Self::new(ResolverErrorKind::Transport, Some(Box::new(e))) 281 .with_context(msg) 282 .with_help("check network connectivity and well-known endpoint availability") 283 } 284} 285 286impl From<serde_json::Error> for ResolverError { 287 fn from(e: serde_json::Error) -> Self { 288 let msg = smol_str::format_smolstr!("{:?}", e); 289 Self::new(ResolverErrorKind::SerdeJson, Some(Box::new(e))) 290 .with_context(msg) 291 .with_help("verify OAuth metadata response format is valid JSON") 292 } 293} 294 295impl From<serde_html_form::ser::Error> for ResolverError { 296 fn from(e: serde_html_form::ser::Error) -> Self { 297 let msg = smol_str::format_smolstr!("{:?}", e); 298 Self::new(ResolverErrorKind::SerdeHtmlForm, Some(Box::new(e))) 299 .with_context(msg) 300 .with_help("check form parameters are serializable") 301 } 302} 303 304impl From<jacquard_common::deps::fluent_uri::ParseError> for ResolverError { 305 fn from(e: jacquard_common::deps::fluent_uri::ParseError) -> Self { 306 let msg = smol_str::format_smolstr!("{:?}", e); 307 Self::new(ResolverErrorKind::Uri, Some(Box::new(e))) 308 .with_context(msg) 309 .with_help("ensure URIs are well-formed (e.g., https://example.com)") 310 } 311} 312 313// // Deprecated - for compatibility with old TransportError usage 314// #[allow(deprecated)] 315// impl From<jacquard_common::error::TransportError> for ResolverError { 316// fn from(e: jacquard_common::error::TransportError) -> Self { 317// Self::transport(e) 318// } 319// } 320 321#[cfg(not(target_arch = "wasm32"))] 322async fn verify_issuer_impl<T: OAuthResolver + Sync + ?Sized>( 323 resolver: &T, 324 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 325 sub: &Did<'_>, 326) -> Result<Uri<String>> { 327 let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 328 if metadata.issuer != server_metadata.issuer { 329 return Err(ResolverError::authorization_server_metadata( 330 "issuer mismatch", 331 )); 332 } 333 Ok(identity 334 .pds_endpoint() 335 .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) 336} 337 338#[cfg(target_arch = "wasm32")] 339async fn verify_issuer_impl<T: OAuthResolver + ?Sized>( 340 resolver: &T, 341 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 342 sub: &Did<'_>, 343) -> Result<Uri<String>> { 344 let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 345 if metadata.issuer != server_metadata.issuer { 346 return Err(ResolverError::authorization_server_metadata( 347 "issuer mismatch", 348 )); 349 } 350 Ok(identity 351 .pds_endpoint() 352 .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) 353} 354 355#[cfg(not(target_arch = "wasm32"))] 356async fn resolve_oauth_impl<T: OAuthResolver + Sync + ?Sized>( 357 resolver: &T, 358 input: &str, 359) -> Result<( 360 OAuthAuthorizationServerMetadata<'static>, 361 Option<DidDocument<'static>>, 362)> { 363 // Allow using an entryway, or PDS url, directly as login input (e.g. 364 // when the user forgot their handle, or when the handle does not 365 // resolve to a DID) 366 Ok(if input.starts_with("https://") { 367 let uri = Uri::parse(input) 368 .map_err(|e| { 369 let err = ResolverError::new(ResolverErrorKind::Uri, Some(Box::new(e))); 370 err.with_context("failed to parse service URL") 371 })? 372 .to_owned(); 373 ( 374 resolver.resolve_from_service(&uri.as_str().into()).await?, 375 None, 376 ) 377 } else { 378 let (metadata, identity) = resolver.resolve_from_identity(input).await?; 379 (metadata, Some(identity)) 380 }) 381} 382 383#[cfg(target_arch = "wasm32")] 384async fn resolve_oauth_impl<T: OAuthResolver + ?Sized>( 385 resolver: &T, 386 input: &str, 387) -> Result<( 388 OAuthAuthorizationServerMetadata<'static>, 389 Option<DidDocument<'static>>, 390)> { 391 // Allow using an entryway, or PDS url, directly as login input (e.g. 392 // when the user forgot their handle, or when the handle does not 393 // resolve to a DID) 394 Ok(if input.starts_with("https://") { 395 let uri = Uri::parse(input) 396 .map_err(|e| { 397 let err = ResolverError::new(ResolverErrorKind::Uri, Some(Box::new(e))); 398 err.with_context("failed to parse service URL") 399 })? 400 .to_owned(); 401 ( 402 resolver.resolve_from_service(&uri.as_str().into()).await?, 403 None, 404 ) 405 } else { 406 let (metadata, identity) = resolver.resolve_from_identity(input).await?; 407 (metadata, Some(identity)) 408 }) 409} 410 411#[cfg(not(target_arch = "wasm32"))] 412async fn resolve_from_service_impl<T: OAuthResolver + Sync + ?Sized>( 413 resolver: &T, 414 input: &CowStr<'_>, 415) -> Result<OAuthAuthorizationServerMetadata<'static>> { 416 // Assume first that input is a PDS URL (as required by ATPROTO) 417 if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 418 return Ok(metadata); 419 } 420 // Fallback to trying to fetch as an issuer (Entryway) 421 resolver.get_authorization_server_metadata(input).await 422} 423 424#[cfg(target_arch = "wasm32")] 425async fn resolve_from_service_impl<T: OAuthResolver + ?Sized>( 426 resolver: &T, 427 input: &CowStr<'_>, 428) -> Result<OAuthAuthorizationServerMetadata<'static>> { 429 // Assume first that input is a PDS URL (as required by ATPROTO) 430 if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 431 return Ok(metadata); 432 } 433 // Fallback to trying to fetch as an issuer (Entryway) 434 resolver.get_authorization_server_metadata(input).await 435} 436 437#[cfg(not(target_arch = "wasm32"))] 438async fn resolve_from_identity_impl<T: OAuthResolver + Sync + ?Sized>( 439 resolver: &T, 440 input: &str, 441) -> Result<( 442 OAuthAuthorizationServerMetadata<'static>, 443 DidDocument<'static>, 444)> { 445 let actor = AtIdentifier::new(input) 446 .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 447 let identity = resolver.resolve_ident_owned(&actor).await?; 448 if let Some(pds) = &identity.pds_endpoint() { 449 use jacquard_common::cowstr::ToCowStr; 450 451 let metadata = resolver 452 .get_resource_server_metadata(&pds.to_cowstr()) 453 .await?; 454 Ok((metadata, identity)) 455 } else { 456 Err(ResolverError::did_document("Did doc lacking pds")) 457 } 458} 459 460#[cfg(target_arch = "wasm32")] 461async fn resolve_from_identity_impl<T: OAuthResolver + ?Sized>( 462 resolver: &T, 463 input: &str, 464) -> Result<( 465 OAuthAuthorizationServerMetadata<'static>, 466 DidDocument<'static>, 467)> { 468 let actor = AtIdentifier::new(input) 469 .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 470 let identity = resolver.resolve_ident_owned(&actor).await?; 471 if let Some(pds) = &identity.pds_endpoint() { 472 let metadata = resolver 473 .get_resource_server_metadata(&pds.to_cowstr()) 474 .await?; 475 Ok((metadata, identity)) 476 } else { 477 Err(ResolverError::did_document("Did doc lacking pds")) 478 } 479} 480 481#[cfg(not(target_arch = "wasm32"))] 482async fn get_authorization_server_metadata_impl<T: HttpClient + Sync + ?Sized>( 483 client: &T, 484 issuer: &CowStr<'_>, 485) -> Result<OAuthAuthorizationServerMetadata<'static>> { 486 let mut md = resolve_authorization_server(client, issuer).await?; 487 md.issuer = issuer.clone().into_static(); 488 Ok(md) 489} 490 491#[cfg(target_arch = "wasm32")] 492async fn get_authorization_server_metadata_impl<T: HttpClient + ?Sized>( 493 client: &T, 494 issuer: &CowStr<'_>, 495) -> Result<OAuthAuthorizationServerMetadata<'static>> { 496 let mut md = resolve_authorization_server(client, issuer).await?; 497 md.issuer = issuer.clone().into_static(); 498 Ok(md) 499} 500 501#[cfg(not(target_arch = "wasm32"))] 502async fn get_resource_server_metadata_impl<T: OAuthResolver + Sync + ?Sized>( 503 resolver: &T, 504 pds: &CowStr<'_>, 505) -> Result<OAuthAuthorizationServerMetadata<'static>> { 506 let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 507 // ATPROTO requires one, and only one, authorization server entry 508 // > That document MUST contain a single item in the authorization_servers array. 509 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 510 let issuer = match &rs_metadata.authorization_servers { 511 Some(servers) if !servers.is_empty() => { 512 if servers.len() > 1 { 513 return Err(ResolverError::protected_resource_metadata( 514 smol_str::format_smolstr!( 515 "unable to determine authorization server for PDS: {pds}" 516 ), 517 )); 518 } 519 &servers[0] 520 } 521 _ => { 522 return Err(ResolverError::protected_resource_metadata( 523 smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), 524 )); 525 } 526 }; 527 let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; 528 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 529 if let Some(protected_resources) = &as_metadata.protected_resources { 530 let resource_url = rs_metadata 531 .resource 532 .strip_suffix('/') 533 .unwrap_or(rs_metadata.resource.as_str()); 534 if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 535 return Err(ResolverError::authorization_server_metadata( 536 smol_str::format_smolstr!( 537 "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 538 rs_metadata.resource, 539 protected_resources 540 ), 541 )); 542 } 543 } 544 545 // TODO: atproot specific validation? 546 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 547 // 548 // eg. 549 // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 550 // if as_metadata.client_id_metadata_document_supported != Some(true) { 551 // return Err(Error::AuthorizationServerMetadata(format!( 552 // "authorization server does not support client_id_metadata_document: {issuer}" 553 // ))); 554 // } 555 556 Ok(as_metadata) 557} 558 559#[cfg(target_arch = "wasm32")] 560async fn get_resource_server_metadata_impl<T: OAuthResolver + ?Sized>( 561 resolver: &T, 562 pds: &CowStr<'_>, 563) -> Result<OAuthAuthorizationServerMetadata<'static>> { 564 let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 565 // ATPROTO requires one, and only one, authorization server entry 566 // > That document MUST contain a single item in the authorization_servers array. 567 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 568 let issuer = match &rs_metadata.authorization_servers { 569 Some(servers) if !servers.is_empty() => { 570 if servers.len() > 1 { 571 return Err(ResolverError::protected_resource_metadata( 572 smol_str::format_smolstr!( 573 "unable to determine authorization server for PDS: {pds}" 574 ), 575 )); 576 } 577 &servers[0] 578 } 579 _ => { 580 return Err(ResolverError::protected_resource_metadata( 581 smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), 582 )); 583 } 584 }; 585 let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; 586 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 587 if let Some(protected_resources) = &as_metadata.protected_resources { 588 let resource_url = rs_metadata 589 .resource 590 .strip_suffix('/') 591 .unwrap_or(rs_metadata.resource.as_str()); 592 if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 593 return Err(ResolverError::authorization_server_metadata( 594 smol_str::format_smolstr!( 595 "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 596 rs_metadata.resource, 597 protected_resources 598 ), 599 )); 600 } 601 } 602 603 // TODO: atproot specific validation? 604 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 605 // 606 // eg. 607 // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 608 // if as_metadata.client_id_metadata_document_supported != Some(true) { 609 // return Err(Error::AuthorizationServerMetadata(format!( 610 // "authorization server does not support client_id_metadata_document: {issuer}" 611 // ))); 612 // } 613 614 Ok(as_metadata) 615} 616 617/// Resolver trait for the AT Protocol OAuth flow. 618/// 619/// `OAuthResolver` extends [`IdentityResolver`] and [`HttpClient`] with the methods needed to 620/// drive the full OAuth flow: resolving an AT identifier (handle or DID) to the authorization 621/// server that protects its PDS, fetching server metadata, and verifying that a token's `sub` 622/// claim is authorized by the expected issuer. 623/// 624/// A default implementation based on [`jacquard_identity::JacquardResolver`] is provided. 625/// Custom implementations are possible for testing or for environments that require 626/// non-standard identity resolution (e.g., federated or offline setups). 627#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 628pub trait OAuthResolver: IdentityResolver + HttpClient { 629 /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`. 630 #[cfg(not(target_arch = "wasm32"))] 631 fn verify_issuer( 632 &self, 633 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 634 sub: &Did<'_>, 635 ) -> impl Future<Output = Result<Uri<String>>> + Send 636 where 637 Self: Sync, 638 { 639 verify_issuer_impl(self, server_metadata, sub) 640 } 641 642 /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`. 643 #[cfg(target_arch = "wasm32")] 644 fn verify_issuer( 645 &self, 646 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 647 sub: &Did<'_>, 648 ) -> impl Future<Output = Result<Uri<String>>> { 649 verify_issuer_impl(self, server_metadata, sub) 650 } 651 652 /// Resolve `input` (a handle, DID, PDS URL, or entryway URL) to OAuth metadata. 653 /// 654 /// When `input` starts with `https://`, it is treated as a service URL and resolved 655 /// directly via [`OAuthResolver::resolve_from_service`]. Otherwise it is treated as an 656 /// AT identifier and resolved via [`OAuthResolver::resolve_from_identity`]. Returns the 657 /// authorization server metadata and, when `input` was an identity, the resolved DID document. 658 #[cfg(not(target_arch = "wasm32"))] 659 fn resolve_oauth( 660 &self, 661 input: &str, 662 ) -> impl Future< 663 Output = Result<( 664 OAuthAuthorizationServerMetadata<'static>, 665 Option<DidDocument<'static>>, 666 )>, 667 > + Send 668 where 669 Self: Sync, 670 { 671 resolve_oauth_impl(self, input) 672 } 673 674 /// Resolve `input` (a handle, DID, PDS URL, or entryway URL) to OAuth metadata. 675 /// 676 /// When `input` starts with `https://`, it is treated as a service URL and resolved 677 /// directly via [`OAuthResolver::resolve_from_service`]. Otherwise it is treated as an 678 /// AT identifier and resolved via [`OAuthResolver::resolve_from_identity`]. Returns the 679 /// authorization server metadata and, when `input` was an identity, the resolved DID document. 680 #[cfg(target_arch = "wasm32")] 681 fn resolve_oauth( 682 &self, 683 input: &str, 684 ) -> impl Future< 685 Output = Result<( 686 OAuthAuthorizationServerMetadata<'static>, 687 Option<DidDocument<'static>>, 688 )>, 689 > { 690 resolve_oauth_impl(self, input) 691 } 692 693 /// Resolve a service URL (PDS or entryway) to its authorization server metadata. 694 /// 695 /// First attempts to fetch the PDS's protected resource metadata; if that fails, falls back 696 /// to treating the URL as an entryway and fetching authorization server metadata directly. 697 #[cfg(not(target_arch = "wasm32"))] 698 fn resolve_from_service( 699 &self, 700 input: &CowStr<'_>, 701 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 702 where 703 Self: Sync, 704 { 705 resolve_from_service_impl(self, input) 706 } 707 708 /// Resolve a service URL to its authorization server metadata. 709 /// 710 /// First attempts to fetch the PDS's protected resource metadata; if that fails, falls back 711 /// to treating the URL as an entryway and fetching authorization server metadata directly. 712 #[cfg(target_arch = "wasm32")] 713 fn resolve_from_service( 714 &self, 715 input: &CowStr<'_>, 716 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 717 resolve_from_service_impl(self, input) 718 } 719 720 /// Resolve an AT identifier (handle or DID) to its authorization server metadata and DID document. 721 #[cfg(not(target_arch = "wasm32"))] 722 fn resolve_from_identity( 723 &self, 724 input: &str, 725 ) -> impl Future< 726 Output = Result<( 727 OAuthAuthorizationServerMetadata<'static>, 728 DidDocument<'static>, 729 )>, 730 > + Send 731 where 732 Self: Sync, 733 { 734 resolve_from_identity_impl(self, input) 735 } 736 737 /// Resolve an AT identifier to its authorization server metadata and DID document. 738 #[cfg(target_arch = "wasm32")] 739 fn resolve_from_identity( 740 &self, 741 input: &str, 742 ) -> impl Future< 743 Output = Result<( 744 OAuthAuthorizationServerMetadata<'static>, 745 DidDocument<'static>, 746 )>, 747 > { 748 resolve_from_identity_impl(self, input) 749 } 750 751 /// Fetch and validate the authorization server metadata for the given issuer URL. 752 /// 753 /// Retrieves the `/.well-known/oauth-authorization-server` document and confirms that 754 /// the `issuer` field in the response matches the requested URL, as required by RFC 8414 §3.3. 755 #[cfg(not(target_arch = "wasm32"))] 756 fn get_authorization_server_metadata( 757 &self, 758 issuer: &CowStr<'_>, 759 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 760 where 761 Self: Sync, 762 { 763 get_authorization_server_metadata_impl(self, issuer) 764 } 765 766 /// Fetch and validate the authorization server metadata for the given issuer URL. 767 /// 768 /// Retrieves the `/.well-known/oauth-authorization-server` document and confirms that 769 /// the `issuer` field in the response matches the requested URL, as required by RFC 8414 §3.3. 770 #[cfg(target_arch = "wasm32")] 771 fn get_authorization_server_metadata( 772 &self, 773 issuer: &CowStr<'_>, 774 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 775 get_authorization_server_metadata_impl(self, issuer) 776 } 777 778 /// Resolve a PDS base URL to its authorization server metadata. 779 #[cfg(not(target_arch = "wasm32"))] 780 fn get_resource_server_metadata( 781 &self, 782 pds: &CowStr<'_>, 783 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 784 where 785 Self: Sync, 786 { 787 get_resource_server_metadata_impl(self, pds) 788 } 789 790 /// Resolve a PDS base URL to its authorization server metadata. 791 #[cfg(target_arch = "wasm32")] 792 fn get_resource_server_metadata( 793 &self, 794 pds: &CowStr<'_>, 795 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 796 get_resource_server_metadata_impl(self, pds) 797 } 798} 799 800/// Fetch and validate the `/.well-known/oauth-authorization-server` document for `server`. 801/// 802/// Per RFC 8414 §3.3 the `issuer` field in the response must equal the `server` URL exactly; 803/// this prevents a compromised server from claiming to be a different issuer. 804pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 805 client: &T, 806 server: &CowStr<'_>, 807) -> Result<OAuthAuthorizationServerMetadata<'static>> { 808 let url = format!( 809 "{}/.well-known/oauth-authorization-server", 810 server.trim_end_matches("/") 811 ); 812 813 let req = Request::builder() 814 .uri(url) 815 .body(Vec::new()) 816 .map_err(|e| ResolverError::transport(e))?; 817 let res = client 818 .send_http(req) 819 .await 820 .map_err(|e| ResolverError::transport(e))?; 821 if res.status() == StatusCode::OK { 822 let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())?; 823 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 824 if metadata.issuer == server.as_str() { 825 Ok(metadata.into_static()) 826 } else { 827 Err(ResolverError::authorization_server_metadata( 828 smol_str::format_smolstr!("invalid issuer: {}", metadata.issuer), 829 )) 830 } 831 } else { 832 Err(ResolverError::http_status(res.status())) 833 } 834} 835 836/// Fetch the `/.well-known/oauth-protected-resource` document for `server`. 837/// 838/// The `resource` field in the response must equal the requested `server` URL, ensuring 839/// that the metadata belongs to the PDS we queried and not a different resource. 840pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 841 client: &T, 842 server: &CowStr<'_>, 843) -> Result<OAuthProtectedResourceMetadata<'static>> { 844 let url = format!( 845 "{}/.well-known/oauth-protected-resource", 846 server.trim_end_matches("/") 847 ); 848 849 let req = Request::builder() 850 .uri(url) 851 .body(Vec::new()) 852 .map_err(|e| ResolverError::transport(e))?; 853 let res = client 854 .send_http(req) 855 .await 856 .map_err(|e| ResolverError::transport(e))?; 857 if res.status() == StatusCode::OK { 858 let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())?; 859 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 860 if metadata.resource == server.as_str() { 861 Ok(metadata.into_static()) 862 } else { 863 Err(ResolverError::authorization_server_metadata( 864 smol_str::format_smolstr!("invalid resource: {}", metadata.resource), 865 )) 866 } 867 } else { 868 Err(ResolverError::http_status(res.status())) 869 } 870} 871 872impl OAuthResolver for jacquard_identity::JacquardResolver {} 873 874#[cfg(test)] 875mod tests { 876 use core::future::Future; 877 use std::{convert::Infallible, sync::Arc}; 878 879 use super::*; 880 use http::{Request as HttpRequest, Response as HttpResponse, StatusCode}; 881 use jacquard_common::http_client::HttpClient; 882 use tokio::sync::Mutex; 883 884 #[derive(Default, Clone)] 885 struct MockHttp { 886 next: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>, 887 } 888 889 impl HttpClient for MockHttp { 890 type Error = Infallible; 891 fn send_http( 892 &self, 893 _request: HttpRequest<Vec<u8>>, 894 ) -> impl Future<Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>> + Send 895 { 896 let next = self.next.clone(); 897 async move { Ok(next.lock().await.take().unwrap()) } 898 } 899 } 900 901 #[tokio::test] 902 async fn authorization_server_http_status() { 903 let client = MockHttp::default(); 904 *client.next.lock().await = Some( 905 HttpResponse::builder() 906 .status(StatusCode::NOT_FOUND) 907 .body(Vec::new()) 908 .unwrap(), 909 ); 910 let issuer = CowStr::new_static("https://issuer"); 911 let err = super::resolve_authorization_server(&client, &issuer) 912 .await 913 .unwrap_err(); 914 assert!(matches!( 915 err.kind(), 916 ResolverErrorKind::HttpStatus(StatusCode::NOT_FOUND) 917 )); 918 } 919 920 #[tokio::test] 921 async fn authorization_server_bad_json() { 922 let client = MockHttp::default(); 923 *client.next.lock().await = Some( 924 HttpResponse::builder() 925 .status(StatusCode::OK) 926 .body(b"{not json}".to_vec()) 927 .unwrap(), 928 ); 929 let issuer = CowStr::new_static("https://issuer"); 930 let err = super::resolve_authorization_server(&client, &issuer) 931 .await 932 .unwrap_err(); 933 assert!(matches!(err.kind(), ResolverErrorKind::SerdeJson)); 934 } 935 936 #[test] 937 fn issuer_plain_string_equality() { 938 // AC5.1: Matching issuer strings pass comparison 939 let issuer1 = CowStr::new_static("https://issuer.example.com"); 940 let issuer2 = CowStr::new_static("https://issuer.example.com"); 941 assert_eq!(issuer1, issuer2); 942 943 // AC5.2: Semantically equivalent but string-different issuers fail comparison 944 // fluent-uri preserves exact input, so these should NOT be equal 945 let issuer_no_slash = CowStr::new_static("https://issuer.example.com"); 946 let issuer_with_slash = CowStr::new_static("https://issuer.example.com/"); 947 assert_ne!(issuer_no_slash, issuer_with_slash); 948 949 // AC5.2: Different query/path parameters should also not be equal 950 let issuer_base = CowStr::new_static("https://issuer.example.com"); 951 let issuer_with_path = CowStr::new_static("https://issuer.example.com/path"); 952 assert_ne!(issuer_base, issuer_with_path); 953 } 954}