A better Rust ATProto crate

url rework

Orual 873b85e9 3bf78e2e

-1
Cargo.lock
··· 2506 "serde_html_form", 2507 "serde_json", 2508 "sha2", 2509 - "signature", 2510 "smol_str", 2511 "thiserror 2.0.17", 2512 "tokio",
··· 2506 "serde_html_form", 2507 "serde_json", 2508 "sha2", 2509 "smol_str", 2510 "thiserror 2.0.17", 2511 "tokio",
-1
Cargo.toml
··· 52 ipld-core = { version = "0.4.2", features = ["serde"] } 53 multihash = "0.19" 54 dashmap = "6.1" 55 - moka = "0.12" 56 mini-moka = "0.10" 57 58 # Proc macros
··· 52 ipld-core = { version = "0.4.2", features = ["serde"] } 53 multihash = "0.19" 54 dashmap = "6.1" 55 mini-moka = "0.10" 56 57 # Proc macros
+1 -1
crates/jacquard-common/Cargo.toml
··· 93 futures-lite = "2.6" 94 95 [package.metadata.docs.rs] 96 - features = [ "crypto-k256", "crypto-k256", "crypto-p256", "websocket", "zstd", "service-auth", "reqwest-client", "crypto"]
··· 93 futures-lite = "2.6" 94 95 [package.metadata.docs.rs] 96 + features = [ "crypto-k256", "crypto-ed22519", "crypto-p256", "websocket", "zstd", "service-auth", "reqwest-client", "crypto"]
-1
crates/jacquard-oauth/Cargo.toml
··· 33 serde_html_form = { workspace = true } 34 miette = { workspace = true } 35 p256 = { workspace = true, features = ["ecdsa"] } 36 - signature = "2" 37 jose-jwa = "0.1" 38 jose-jwk = { workspace = true, features = ["p256"] } 39 chrono.workspace = true
··· 33 serde_html_form = { workspace = true } 34 miette = { workspace = true } 35 p256 = { workspace = true, features = ["ecdsa"] } 36 jose-jwa = "0.1" 37 jose-jwk = { workspace = true, features = ["p256"] } 38 chrono.workspace = true
+30 -21
crates/jacquard-oauth/src/atproto.rs
··· 152 #[derive(serde::Serialize)] 153 struct Parameters<'a> { 154 #[serde(skip_serializing_if = "Option::is_none")] 155 - redirect_uri: Option<Vec<Url>>, 156 #[serde(skip_serializing_if = "Option::is_none")] 157 scope: Option<CowStr<'a>>, 158 } 159 let query = serde_html_form::to_string(Parameters { 160 - redirect_uri: redirect_uris.clone(), 161 scope: scopes 162 .as_ref() 163 .map(|s| Scope::serialize_multiple(s.as_slice())), 164 }) 165 .ok(); 166 - let mut client_id = String::from("http://localhost"); 167 if let Some(query) = query 168 && !query.is_empty() 169 { ··· 173 client_id: Url::parse(&client_id).unwrap(), 174 client_uri: None, 175 redirect_uris: redirect_uris.unwrap_or(vec![ 176 - Url::from_str("http://127.0.0.1/").unwrap(), 177 - Url::from_str("http://[::1]/").unwrap(), 178 ]), 179 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 180 scopes: scopes.unwrap_or(vec![Scope::Atproto]), ··· 216 } else { 217 (AuthMethod::None, None, None) 218 }; 219 - 220 Ok(OAuthClientMetadata { 221 - client_id: metadata.client_id.to_cowstr().into_static(), 222 - client_uri: metadata.client_uri.map(|u| u.to_cowstr().into_static()), 223 - redirect_uris: metadata 224 - .redirect_uris 225 - .iter() 226 - .map(|u| u.to_cowstr().into_static()) 227 - .collect(), 228 token_endpoint_auth_method: Some(auth_method.into()), 229 grant_types: if keyset.is_some() { 230 Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()) ··· 233 }, 234 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 235 dpop_bound_access_tokens: Some(true), 236 - jwks_uri: jwks_uri.map(|u| u.to_cowstr().into_static()), 237 jwks, 238 token_endpoint_auth_signing_alg: if keyset.is_some() { 239 Some(CowStr::new_static("ES256")) ··· 275 client_id: CowStr::new_static("http://localhost"), 276 client_uri: None, 277 redirect_uris: vec![ 278 - CowStr::new_static("http://127.0.0.1/"), 279 - CowStr::new_static("http://[::1]/"), 280 ], 281 scope: Some(CowStr::new_static("atproto")), 282 grant_types: None, ··· 313 .expect("failed to convert metadata"), 314 OAuthClientMetadata { 315 client_id: CowStr::new_static( 316 - "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric" 317 ), 318 client_uri: None, 319 redirect_uris: vec![ ··· 354 out, 355 OAuthClientMetadata { 356 client_id: CowStr::new_static( 357 - "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2F" 358 ), 359 client_uri: None, 360 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], ··· 385 out, 386 OAuthClientMetadata { 387 client_id: CowStr::new_static( 388 - "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2F" 389 ), 390 client_uri: None, 391 redirect_uris: vec![CowStr::new_static("http://127.0.0.1:8000")], ··· 416 out, 417 OAuthClientMetadata { 418 client_id: CowStr::new_static( 419 - "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2F" 420 ), 421 client_uri: None, 422 - redirect_uris: vec![CowStr::new_static("http://127.0.0.1/")], 423 scope: Some(CowStr::new_static("atproto")), 424 grant_types: None, 425 token_endpoint_auth_method: Some(AuthMethod::None.into()),
··· 152 #[derive(serde::Serialize)] 153 struct Parameters<'a> { 154 #[serde(skip_serializing_if = "Option::is_none")] 155 + redirect_uri: Option<Vec<CowStr<'a>>>, 156 #[serde(skip_serializing_if = "Option::is_none")] 157 scope: Option<CowStr<'a>>, 158 } 159 let query = serde_html_form::to_string(Parameters { 160 + redirect_uri: redirect_uris.as_ref().map(|u| { 161 + u.iter() 162 + .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static()) 163 + .collect() 164 + }), 165 scope: scopes 166 .as_ref() 167 .map(|s| Scope::serialize_multiple(s.as_slice())), 168 }) 169 .ok(); 170 + let mut client_id = String::from("http://localhost/"); 171 if let Some(query) = query 172 && !query.is_empty() 173 { ··· 177 client_id: Url::parse(&client_id).unwrap(), 178 client_uri: None, 179 redirect_uris: redirect_uris.unwrap_or(vec![ 180 + Url::from_str("http://127.0.0.1").unwrap(), 181 + Url::from_str("http://[::1]").unwrap(), 182 ]), 183 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 184 scopes: scopes.unwrap_or(vec![Scope::Atproto]), ··· 220 } else { 221 (AuthMethod::None, None, None) 222 }; 223 + let client_id = metadata.client_id.as_str().trim_end_matches("/"); 224 + let client_uri = metadata 225 + .client_uri 226 + .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static()); 227 + let redirect_uris = metadata 228 + .redirect_uris 229 + .iter() 230 + .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static()) 231 + .collect(); 232 + let jwks_uri = jwks_uri.map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static()); 233 Ok(OAuthClientMetadata { 234 + client_id: client_id.to_cowstr().into_static(), 235 + client_uri, 236 + redirect_uris, 237 token_endpoint_auth_method: Some(auth_method.into()), 238 grant_types: if keyset.is_some() { 239 Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()) ··· 242 }, 243 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 244 dpop_bound_access_tokens: Some(true), 245 + jwks_uri, 246 jwks, 247 token_endpoint_auth_signing_alg: if keyset.is_some() { 248 Some(CowStr::new_static("ES256")) ··· 284 client_id: CowStr::new_static("http://localhost"), 285 client_uri: None, 286 redirect_uris: vec![ 287 + CowStr::new_static("http://127.0.0.1"), 288 + CowStr::new_static("http://[::1]"), 289 ], 290 scope: Some(CowStr::new_static("atproto")), 291 grant_types: None, ··· 322 .expect("failed to convert metadata"), 323 OAuthClientMetadata { 324 client_id: CowStr::new_static( 325 + "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric" 326 ), 327 client_uri: None, 328 redirect_uris: vec![ ··· 363 out, 364 OAuthClientMetadata { 365 client_id: CowStr::new_static( 366 + "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1" 367 ), 368 client_uri: None, 369 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], ··· 394 out, 395 OAuthClientMetadata { 396 client_id: CowStr::new_static( 397 + "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A8000" 398 ), 399 client_uri: None, 400 redirect_uris: vec![CowStr::new_static("http://127.0.0.1:8000")], ··· 425 out, 426 OAuthClientMetadata { 427 client_id: CowStr::new_static( 428 + "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1" 429 ), 430 client_uri: None, 431 + redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 432 scope: Some(CowStr::new_static("atproto")), 433 grant_types: None, 434 token_endpoint_auth_method: Some(AuthMethod::None.into()),
+1 -1
crates/jacquard-oauth/src/client.rs
··· 593 594 async fn set_base_uri(&self, url: Url) { 595 let mut guard = self.data.write().await; 596 - guard.host_url = url.to_cowstr().into_static(); 597 } 598 599 async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
··· 593 594 async fn set_base_uri(&self, url: Url) { 595 let mut guard = self.data.write().await; 596 + guard.host_url = url.as_str().trim_end_matches("/").to_cowstr().into_static(); 597 } 598 599 async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
+6 -3
crates/jacquard-oauth/src/resolver.rs
··· 5 use http::{Request, StatusCode}; 6 use jacquard_common::CowStr; 7 use jacquard_common::IntoStatic; 8 use jacquard_common::types::did_doc::DidDocument; 9 use jacquard_common::types::ident::AtIdentifier; 10 use jacquard_common::{http_client::HttpClient, types::did::Did}; ··· 423 // resolve to a DID) 424 Ok(if input.starts_with("https://") { 425 let url = Url::parse(input).map_err(|_| ResolverError::not_found())?; 426 - (resolver.resolve_from_service(&url).await?, None) 427 } else { 428 let (metadata, identity) = resolver.resolve_from_identity(input).await?; 429 (metadata, Some(identity)) ··· 491 .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 492 let identity = resolver.resolve_ident_owned(&actor).await?; 493 if let Some(pds) = &identity.pds_endpoint() { 494 - let metadata = resolver.get_resource_server_metadata(pds).await?; 495 Ok((metadata, identity)) 496 } else { 497 Err(ResolverError::did_document("Did doc lacking pds")) ··· 514 issuer: &CowStr<'_>, 515 ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 516 let mut md = resolve_authorization_server(client, issuer).await?; 517 - md.issuer = issuer.into_static(); 518 Ok(md) 519 } 520
··· 5 use http::{Request, StatusCode}; 6 use jacquard_common::CowStr; 7 use jacquard_common::IntoStatic; 8 + use jacquard_common::cowstr::ToCowStr; 9 use jacquard_common::types::did_doc::DidDocument; 10 use jacquard_common::types::ident::AtIdentifier; 11 use jacquard_common::{http_client::HttpClient, types::did::Did}; ··· 424 // resolve to a DID) 425 Ok(if input.starts_with("https://") { 426 let url = Url::parse(input).map_err(|_| ResolverError::not_found())?; 427 + (resolver.resolve_from_service(&url.to_cowstr()).await?, None) 428 } else { 429 let (metadata, identity) = resolver.resolve_from_identity(input).await?; 430 (metadata, Some(identity)) ··· 492 .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 493 let identity = resolver.resolve_ident_owned(&actor).await?; 494 if let Some(pds) = &identity.pds_endpoint() { 495 + let metadata = resolver 496 + .get_resource_server_metadata(&pds.to_cowstr()) 497 + .await?; 498 Ok((metadata, identity)) 499 } else { 500 Err(ResolverError::did_document("Did doc lacking pds")) ··· 517 issuer: &CowStr<'_>, 518 ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 519 let mut md = resolve_authorization_server(client, issuer).await?; 520 + md.issuer = issuer.clone().into_static(); 521 Ok(md) 522 } 523
+1 -1
crates/jacquard/Cargo.toml
··· 144 bytes.workspace = true 145 http.workspace = true 146 miette = { workspace = true } 147 - reqwest = { workspace = true, features = ["charset", "json", "gzip"] } 148 serde.workspace = true 149 serde_html_form.workspace = true 150 serde_json.workspace = true
··· 144 bytes.workspace = true 145 http.workspace = true 146 miette = { workspace = true } 147 + reqwest = { workspace = true, features = ["json", "gzip"] } 148 serde.workspace = true 149 serde_html_form.workspace = true 150 serde_json.workspace = true
+3 -2
crates/jacquard/src/client.rs
··· 254 let base_uri = self.base_uri().await; 255 let base_uri = Url::parse(&base_uri).expect("base_uri should be valid url"); 256 self.resolver 257 - .xrpc(base_uri.clone()) 258 .with_options(opts.clone()) 259 .send(&request) 260 .await ··· 287 { 288 async move { 289 let base_uri = self.base_uri().await; 290 self.resolver 291 - .xrpc(base_uri.clone()) 292 .with_options(opts.clone()) 293 .send(&request) 294 .await
··· 254 let base_uri = self.base_uri().await; 255 let base_uri = Url::parse(&base_uri).expect("base_uri should be valid url"); 256 self.resolver 257 + .xrpc(base_uri) 258 .with_options(opts.clone()) 259 .send(&request) 260 .await ··· 287 { 288 async move { 289 let base_uri = self.base_uri().await; 290 + let base_uri = Url::parse(&base_uri).expect("base_uri should be valid url"); 291 self.resolver 292 + .xrpc(base_uri) 293 .with_options(opts.clone()) 294 .send(&request) 295 .await
+3 -2
crates/jacquard/src/client/credential_session.rs
··· 286 } 287 // Activate 288 *self.key.write().await = Some(key); 289 - *self.endpoint.write().await = Some(pds.to_cowstr().into_static()); 290 291 Ok(session) 292 } ··· 442 443 async fn set_base_uri(&self, url: Url) { 444 let mut guard = self.endpoint.write().await; 445 - *guard = Some(url.to_cowstr().into_static()); 446 } 447 448 async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
··· 286 } 287 // Activate 288 *self.key.write().await = Some(key); 289 + *self.endpoint.write().await = 290 + Some(pds.as_str().trim_end_matches("/").to_cowstr().into_static()); 291 292 Ok(session) 293 } ··· 443 444 async fn set_base_uri(&self, url: Url) { 445 let mut guard = self.endpoint.write().await; 446 + *guard = Some(url.as_str().trim_end_matches("/").to_cowstr().into_static()); 447 } 448 449 async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>>
+2 -5
crates/jacquard/tests/credential_session.rs
··· 169 let session = CredentialSession::new(store.clone(), client.clone()); 170 171 // Before login, default endpoint should be public appview 172 - assert_eq!( 173 - session.endpoint().await.as_str(), 174 - "https://public.bsky.app/" 175 - ); 176 177 // Login using handle; resolves to PDS and persists session 178 session ··· 187 .expect("login ok"); 188 189 // Endpoint switches to PDS 190 - assert_eq!(session.endpoint().await.as_str(), "https://pds/"); 191 192 // Send a request that will first 401 (ExpiredToken), then refresh, then succeed 193 let resp = session
··· 169 let session = CredentialSession::new(store.clone(), client.clone()); 170 171 // Before login, default endpoint should be public appview 172 + assert_eq!(session.endpoint().await.as_str(), "https://public.bsky.app"); 173 174 // Login using handle; resolves to PDS and persists session 175 session ··· 184 .expect("login ok"); 185 186 // Endpoint switches to PDS 187 + assert_eq!(session.endpoint().await.as_str(), "https://pds"); 188 189 // Send a request that will first 401 (ExpiredToken), then refresh, then succeed 190 let resp = session
+1 -1
rust-toolchain.toml
··· 1 [toolchain] 2 - channel = "stable" 3 profile = "default" 4 targets = [ "wasm32-unknown-unknown" ]
··· 1 [toolchain] 2 + channel = "nightly" 3 profile = "default" 4 targets = [ "wasm32-unknown-unknown" ]