A better Rust ATProto crate
1

Configure Feed

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

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