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::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}