Skip to main content

jacquard_oauth/
resolver.rs

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}