A better Rust ATProto crate
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}