···1//! Error types for XRPC client operations
23-use crate::types::xrpc::EncodeError;
4use bytes::Bytes;
56/// Client error type wrapping all possible error conditions
···1//! Error types for XRPC client operations
23+use crate::xrpc::EncodeError;
4use bytes::Bytes;
56/// Client error type wrapping all possible error conditions
+2
crates/jacquard-common/src/lib.rs
···21pub mod session;
22/// Baseline fundamental AT Protocol data types.
23pub mod types;
002425/// Authorization token types for XRPC requests.
26#[derive(Debug, Clone)]
···21pub mod session;
22/// Baseline fundamental AT Protocol data types.
23pub mod types;
24+/// XRPC protocol types and traits
25+pub mod xrpc;
2627/// Authorization token types for XRPC requests.
28#[derive(Debug, Clone)]
+2-4
crates/jacquard-common/src/types.rs
···8pub mod cid;
9/// Repository collection trait for records
10pub mod collection;
0011/// AT Protocol datetime string type
12pub mod datetime;
13/// Decentralized Identifier (DID) types and validation
14pub mod did;
15/// DID Document types and helpers
16pub mod did_doc;
17-/// Crypto helpers for keys (Multikey decoding, conversions)
18-pub mod crypto;
19/// AT Protocol handle types and validation
20pub mod handle;
21/// AT Protocol identifier types (handle or DID)
···38pub mod uri;
39/// Generic data value types for lexicon data model
40pub mod value;
41-/// XRPC protocol types and traits
42-pub mod xrpc;
4344/// Trait for a constant string literal type
45pub trait Literal: Clone + Copy + PartialEq + Eq + Send + Sync + 'static {
···8pub mod cid;
9/// Repository collection trait for records
10pub mod collection;
11+/// Crypto helpers for keys (Multikey decoding, conversions)
12+pub mod crypto;
13/// AT Protocol datetime string type
14pub mod datetime;
15/// Decentralized Identifier (DID) types and validation
16pub mod did;
17/// DID Document types and helpers
18pub mod did_doc;
0019/// AT Protocol handle types and validation
20pub mod handle;
21/// AT Protocol identifier types (handle or DID)
···38pub mod uri;
39/// Generic data value types for lexicon data model
40pub mod value;
004142/// Trait for a constant string literal type
43pub trait Literal: Clone + Copy + PartialEq + Eq + Send + Sync + 'static {
···590 }
591592 // IdentityResolver methods won't be called in these tests; provide stubs.
593- #[async_trait::async_trait]
594 impl IdentityResolver for MockClient {
595 fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
596 use std::sync::LazyLock;
···590 }
591592 // IdentityResolver methods won't be called in these tests; provide stubs.
0593 impl IdentityResolver for MockClient {
594 fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
595 use std::sync::LazyLock;
+113-96
crates/jacquard-oauth/src/resolver.rs
···115 Uri(#[from] url::ParseError),
116}
117118-#[async_trait::async_trait]
119pub trait OAuthResolver: IdentityResolver + HttpClient {
120- async fn verify_issuer(
121 &self,
122 server_metadata: &OAuthAuthorizationServerMetadata<'_>,
123 sub: &Did<'_>,
124- ) -> Result<Url, ResolverError> {
125- let (metadata, identity) = self.resolve_from_identity(sub).await?;
126- if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
127- return Err(ResolverError::AuthorizationServerMetadata(
128- "issuer mismatch".to_string(),
129- ));
00000130 }
131- Ok(identity
132- .pds_endpoint()
133- .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
134 }
135- async fn resolve_oauth(
136 &self,
137 input: &str,
138- ) -> Result<
139- (
140- OAuthAuthorizationServerMetadata<'static>,
141- Option<DidDocument<'static>>,
142- ),
143- ResolverError,
00144 > {
145 // Allow using an entryway, or PDS url, directly as login input (e.g.
146 // when the user forgot their handle, or when the handle does not
147 // resolve to a DID)
148- Ok(if input.starts_with("https://") {
149- let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
150- (self.resolve_from_service(&url).await?, None)
151- } else {
152- let (metadata, identity) = self.resolve_from_identity(input).await?;
153- (metadata, Some(identity))
154- })
00155 }
156- async fn resolve_from_service(
157 &self,
158 input: &Url,
159- ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
160- // Assume first that input is a PDS URL (as required by ATPROTO)
161- if let Ok(metadata) = self.get_resource_server_metadata(input).await {
162- return Ok(metadata);
00000163 }
164- // Fallback to trying to fetch as an issuer (Entryway)
165- self.get_authorization_server_metadata(input).await
166 }
167- async fn resolve_from_identity(
168 &self,
169 input: &str,
170- ) -> Result<
171- (
172- OAuthAuthorizationServerMetadata<'static>,
173- DidDocument<'static>,
174- ),
175- ResolverError,
00176 > {
177- let actor = AtIdentifier::new(input)
178- .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
179- let identity = self.resolve_ident_owned(&actor).await?;
180- if let Some(pds) = &identity.pds_endpoint() {
181- let metadata = self.get_resource_server_metadata(pds).await?;
182- Ok((metadata, identity))
183- } else {
184- Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
00185 }
186 }
187- async fn get_authorization_server_metadata(
188 &self,
189 issuer: &Url,
190- ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
191- let mut md = resolve_authorization_server(self, issuer).await?;
192- // Normalize issuer string to the input URL representation to avoid slash quirks
193- md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static();
194- Ok(md)
000195 }
196- async fn get_resource_server_metadata(
197 &self,
198 pds: &Url,
199- ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
200- let rs_metadata = resolve_protected_resource_info(self, pds).await?;
201- // ATPROTO requires one, and only one, authorization server entry
202- // > That document MUST contain a single item in the authorization_servers array.
203- // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
204- let issuer = match &rs_metadata.authorization_servers {
205- Some(servers) if !servers.is_empty() => {
206- if servers.len() > 1 {
000000000207 return Err(ResolverError::ProtectedResourceMetadata(format!(
208- "unable to determine authorization server for PDS: {pds}"
00000000000000209 )));
210 }
211- &servers[0]
212- }
213- _ => {
214- return Err(ResolverError::ProtectedResourceMetadata(format!(
215- "no authorization server found for PDS: {pds}"
216- )));
217 }
218- };
219- let as_metadata = self.get_authorization_server_metadata(issuer).await?;
220- // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
221- if let Some(protected_resources) = &as_metadata.protected_resources {
222- let resource_url = rs_metadata
223- .resource
224- .strip_suffix('/')
225- .unwrap_or(rs_metadata.resource.as_str());
226- if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
227- return Err(ResolverError::AuthorizationServerMetadata(format!(
228- "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
229- rs_metadata.resource, protected_resources
230- )));
231- }
232- }
233234- // TODO: atproot specific validation?
235- // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
236- //
237- // eg.
238- // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
239- // if as_metadata.client_id_metadata_document_supported != Some(true) {
240- // return Err(Error::AuthorizationServerMetadata(format!(
241- // "authorization server does not support client_id_metadata_document: {issuer}"
242- // )));
243- // }
244245- Ok(as_metadata)
0246 }
247}
248···316 }
317}
318319-#[async_trait::async_trait]
320impl OAuthResolver for jacquard_identity::JacquardResolver {}
321322#[cfg(test)]
···115 Uri(#[from] url::ParseError),
116}
1170118pub trait OAuthResolver: IdentityResolver + HttpClient {
119+ fn verify_issuer(
120 &self,
121 server_metadata: &OAuthAuthorizationServerMetadata<'_>,
122 sub: &Did<'_>,
123+ ) -> impl std::future::Future<Output = Result<Url, ResolverError>> {
124+ async {
125+ let (metadata, identity) = self.resolve_from_identity(sub).await?;
126+ if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
127+ return Err(ResolverError::AuthorizationServerMetadata(
128+ "issuer mismatch".to_string(),
129+ ));
130+ }
131+ Ok(identity
132+ .pds_endpoint()
133+ .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?)
134 }
000135 }
136+ fn resolve_oauth(
137 &self,
138 input: &str,
139+ ) -> impl Future<
140+ Output = Result<
141+ (
142+ OAuthAuthorizationServerMetadata<'static>,
143+ Option<DidDocument<'static>>,
144+ ),
145+ ResolverError,
146+ >,
147 > {
148 // Allow using an entryway, or PDS url, directly as login input (e.g.
149 // when the user forgot their handle, or when the handle does not
150 // resolve to a DID)
151+ async {
152+ Ok(if input.starts_with("https://") {
153+ let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
154+ (self.resolve_from_service(&url).await?, None)
155+ } else {
156+ let (metadata, identity) = self.resolve_from_identity(input).await?;
157+ (metadata, Some(identity))
158+ })
159+ }
160 }
161+ fn resolve_from_service(
162 &self,
163 input: &Url,
164+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>>
165+ {
166+ async {
167+ // Assume first that input is a PDS URL (as required by ATPROTO)
168+ if let Ok(metadata) = self.get_resource_server_metadata(input).await {
169+ return Ok(metadata);
170+ }
171+ // Fallback to trying to fetch as an issuer (Entryway)
172+ self.get_authorization_server_metadata(input).await
173 }
00174 }
175+ fn resolve_from_identity(
176 &self,
177 input: &str,
178+ ) -> impl Future<
179+ Output = Result<
180+ (
181+ OAuthAuthorizationServerMetadata<'static>,
182+ DidDocument<'static>,
183+ ),
184+ ResolverError,
185+ >,
186 > {
187+ async {
188+ let actor = AtIdentifier::new(input)
189+ .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
190+ let identity = self.resolve_ident_owned(&actor).await?;
191+ if let Some(pds) = &identity.pds_endpoint() {
192+ let metadata = self.get_resource_server_metadata(pds).await?;
193+ Ok((metadata, identity))
194+ } else {
195+ Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
196+ }
197 }
198 }
199+ fn get_authorization_server_metadata(
200 &self,
201 issuer: &Url,
202+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>>
203+ {
204+ async {
205+ let mut md = resolve_authorization_server(self, issuer).await?;
206+ // Normalize issuer string to the input URL representation to avoid slash quirks
207+ md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static();
208+ Ok(md)
209+ }
210 }
211+ fn get_resource_server_metadata(
212 &self,
213 pds: &Url,
214+ ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>>
215+ {
216+ async move {
217+ let rs_metadata = resolve_protected_resource_info(self, pds).await?;
218+ // ATPROTO requires one, and only one, authorization server entry
219+ // > That document MUST contain a single item in the authorization_servers array.
220+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
221+ let issuer = match &rs_metadata.authorization_servers {
222+ Some(servers) if !servers.is_empty() => {
223+ if servers.len() > 1 {
224+ return Err(ResolverError::ProtectedResourceMetadata(format!(
225+ "unable to determine authorization server for PDS: {pds}"
226+ )));
227+ }
228+ &servers[0]
229+ }
230+ _ => {
231 return Err(ResolverError::ProtectedResourceMetadata(format!(
232+ "no authorization server found for PDS: {pds}"
233+ )));
234+ }
235+ };
236+ let as_metadata = self.get_authorization_server_metadata(issuer).await?;
237+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
238+ if let Some(protected_resources) = &as_metadata.protected_resources {
239+ let resource_url = rs_metadata
240+ .resource
241+ .strip_suffix('/')
242+ .unwrap_or(rs_metadata.resource.as_str());
243+ if !protected_resources.contains(&CowStr::Borrowed(resource_url)) {
244+ return Err(ResolverError::AuthorizationServerMetadata(format!(
245+ "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}",
246+ rs_metadata.resource, protected_resources
247 )));
248 }
000000249 }
000000000000000250251+ // TODO: atproot specific validation?
252+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
253+ //
254+ // eg.
255+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
256+ // if as_metadata.client_id_metadata_document_supported != Some(true) {
257+ // return Err(Error::AuthorizationServerMetadata(format!(
258+ // "authorization server does not support client_id_metadata_document: {issuer}"
259+ // )));
260+ // }
261262+ Ok(as_metadata)
263+ }
264 }
265}
266···334 }
335}
3360337impl OAuthResolver for jacquard_identity::JacquardResolver {}
338339#[cfg(test)]