A better Rust ATProto crate
103
fork

Configure Feed

Select the types of activity you want to include in your feed.

serde bytes helpers for bytes fields in json

Orual 76e7ad63 cbd55399

+363 -40
+78
Cargo.lock
··· 215 215 ] 216 216 217 217 [[package]] 218 + name = "atomic-polyfill" 219 + version = "1.0.3" 220 + source = "registry+https://github.com/rust-lang/crates.io-index" 221 + checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" 222 + dependencies = [ 223 + "critical-section", 224 + ] 225 + 226 + [[package]] 218 227 name = "atomic-waker" 219 228 version = "1.1.2" 220 229 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 745 754 ] 746 755 747 756 [[package]] 757 + name = "cobs" 758 + version = "0.3.0" 759 + source = "registry+https://github.com/rust-lang/crates.io-index" 760 + checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" 761 + dependencies = [ 762 + "thiserror 2.0.17", 763 + ] 764 + 765 + [[package]] 748 766 name = "color_quant" 749 767 version = "1.1.0" 750 768 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 881 899 ] 882 900 883 901 [[package]] 902 + name = "critical-section" 903 + version = "1.2.0" 904 + source = "registry+https://github.com/rust-lang/crates.io-index" 905 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 906 + 907 + [[package]] 884 908 name = "crossbeam-channel" 885 909 version = "0.5.15" 886 910 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1232 1256 dependencies = [ 1233 1257 "serde", 1234 1258 ] 1259 + 1260 + [[package]] 1261 + name = "embedded-io" 1262 + version = "0.4.0" 1263 + source = "registry+https://github.com/rust-lang/crates.io-index" 1264 + checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" 1265 + 1266 + [[package]] 1267 + name = "embedded-io" 1268 + version = "0.6.1" 1269 + source = "registry+https://github.com/rust-lang/crates.io-index" 1270 + checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" 1235 1271 1236 1272 [[package]] 1237 1273 name = "encode_unicode" ··· 1722 1758 ] 1723 1759 1724 1760 [[package]] 1761 + name = "hash32" 1762 + version = "0.2.1" 1763 + source = "registry+https://github.com/rust-lang/crates.io-index" 1764 + checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" 1765 + dependencies = [ 1766 + "byteorder", 1767 + ] 1768 + 1769 + [[package]] 1725 1770 name = "hashbrown" 1726 1771 version = "0.12.3" 1727 1772 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1738 1783 version = "0.16.0" 1739 1784 source = "registry+https://github.com/rust-lang/crates.io-index" 1740 1785 checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1786 + 1787 + [[package]] 1788 + name = "heapless" 1789 + version = "0.7.17" 1790 + source = "registry+https://github.com/rust-lang/crates.io-index" 1791 + checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" 1792 + dependencies = [ 1793 + "atomic-polyfill", 1794 + "hash32", 1795 + "rustc_version", 1796 + "serde", 1797 + "spin 0.9.8", 1798 + "stable_deref_trait", 1799 + ] 1741 1800 1742 1801 [[package]] 1743 1802 name = "heck" ··· 2307 2366 "miette", 2308 2367 "rustversion", 2309 2368 "serde", 2369 + "serde_bytes", 2310 2370 "serde_ipld_dagcbor", 2311 2371 "thiserror 2.0.17", 2312 2372 "unicode-segmentation", ··· 2367 2427 "n0-future", 2368 2428 "ouroboros", 2369 2429 "p256", 2430 + "postcard", 2370 2431 "rand 0.9.2", 2371 2432 "regex", 2372 2433 "regex-lite", 2373 2434 "reqwest", 2374 2435 "serde", 2436 + "serde_bytes", 2375 2437 "serde_html_form", 2376 2438 "serde_ipld_dagcbor", 2377 2439 "serde_json", ··· 3487 3549 ] 3488 3550 3489 3551 [[package]] 3552 + name = "postcard" 3553 + version = "1.1.3" 3554 + source = "registry+https://github.com/rust-lang/crates.io-index" 3555 + checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" 3556 + dependencies = [ 3557 + "cobs", 3558 + "embedded-io 0.4.0", 3559 + "embedded-io 0.6.1", 3560 + "heapless", 3561 + "serde", 3562 + ] 3563 + 3564 + [[package]] 3490 3565 name = "potential_utf" 3491 3566 version = "0.1.3" 3492 3567 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4585 4660 version = "0.9.8" 4586 4661 source = "registry+https://github.com/rust-lang/crates.io-index" 4587 4662 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 4663 + dependencies = [ 4664 + "lock_api", 4665 + ] 4588 4666 4589 4667 [[package]] 4590 4668 name = "spin"
+1
crates/jacquard-api/Cargo.toml
··· 26 26 serde_ipld_dagcbor.workspace = true 27 27 thiserror.workspace = true 28 28 unicode-segmentation = "1.12" 29 + serde_bytes = "0.11" 29 30 30 31 31 32 [lints.rust]
+3
crates/jacquard-common/Cargo.toml
··· 45 45 thiserror.workspace = true 46 46 url.workspace = true 47 47 http.workspace = true 48 + serde_bytes = "0.11" 49 + 48 50 49 51 reqwest = { workspace = true, optional = true, features = ["json", "charset", "gzip", "stream"] } 50 52 serde_ipld_dagcbor.workspace = true ··· 58 60 tokio-tungstenite-wasm = { version = "0.4", features = ["rustls-tls-native-roots"], optional = true } 59 61 ciborium = {version = "0.2.0", optional = true } 60 62 zstd = { version = "0.13", optional = true } 63 + postcard = { version = "1.1.3", features = ["use-std"] } 61 64 62 65 [target.'cfg(target_family = "wasm")'.dependencies] 63 66 getrandom = { version = "0.3.4", features = ["wasm_js"] }
+6 -4
crates/jacquard-common/src/lib.rs
··· 219 219 /// Baseline fundamental AT Protocol data types. 220 220 pub mod types; 221 221 // XRPC protocol types and traits 222 - pub mod xrpc; 222 + pub mod opt_serde_bytes_helper; 223 + pub mod serde_bytes_helper; 223 224 /// Stream abstractions for HTTP request/response bodies. 224 225 #[cfg(feature = "streaming")] 225 226 pub mod stream; 227 + pub mod xrpc; 226 228 227 229 #[cfg(feature = "streaming")] 228 - pub use stream::{ByteStream, ByteSink, StreamError, StreamErrorKind}; 230 + pub use stream::{ByteSink, ByteStream, StreamError, StreamErrorKind}; 229 231 230 232 #[cfg(feature = "streaming")] 231 233 pub use xrpc::StreamingResponse; ··· 238 240 239 241 #[cfg(feature = "websocket")] 240 242 pub use websocket::{ 241 - tungstenite_client::TungsteniteClient, CloseCode, CloseFrame, WebSocketClient, 242 - WebSocketConnection, WsMessage, WsSink, WsStream, WsText, 243 + CloseCode, CloseFrame, WebSocketClient, WebSocketConnection, WsMessage, WsSink, WsStream, 244 + WsText, tungstenite_client::TungsteniteClient, 243 245 }; 244 246 245 247 pub use types::value::*;
+25
crates/jacquard-common/src/opt_serde_bytes_helper.rs
··· 1 + //! Custom serde helpers for bytes::Bytes using serde_bytes 2 + 3 + use bytes::Bytes; 4 + use serde::{Deserializer, Serializer}; 5 + 6 + /// Serialize Bytes as a CBOR byte string 7 + pub fn serialize<S>(bytes: &Option<Bytes>, serializer: S) -> Result<S::Ok, S::Error> 8 + where 9 + S: Serializer, 10 + { 11 + if let Some(bytes) = bytes { 12 + serde_bytes::serialize(bytes.as_ref(), serializer) 13 + } else { 14 + serializer.serialize_none() 15 + } 16 + } 17 + 18 + /// Deserialize Bytes from a CBOR byte string 19 + pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Bytes>, D::Error> 20 + where 21 + D: Deserializer<'de>, 22 + { 23 + let vec: Option<Vec<u8>> = serde_bytes::deserialize(deserializer)?; 24 + Ok(vec.map(Bytes::from)) 25 + }
+21
crates/jacquard-common/src/serde_bytes_helper.rs
··· 1 + //! Custom serde helpers for bytes::Bytes using serde_bytes 2 + 3 + use bytes::Bytes; 4 + use serde::{Deserializer, Serializer}; 5 + 6 + /// Serialize Bytes as a CBOR byte string 7 + pub fn serialize<S>(bytes: &Bytes, serializer: S) -> Result<S::Ok, S::Error> 8 + where 9 + S: Serializer, 10 + { 11 + serde_bytes::serialize(bytes.as_ref(), serializer) 12 + } 13 + 14 + /// Deserialize Bytes from a CBOR byte string 15 + pub fn deserialize<'de, D>(deserializer: D) -> Result<Bytes, D::Error> 16 + where 17 + D: Deserializer<'de>, 18 + { 19 + let vec: Vec<u8> = serde_bytes::deserialize(deserializer)?; 20 + Ok(Bytes::from(vec)) 21 + }
+1
crates/jacquard-common/src/types/language.rs
··· 1 + #[allow(unused)] 1 2 use langtag::InvalidLangTag; 2 3 use serde::{Deserialize, Deserializer, Serialize, de::Error}; 3 4 use smol_str::{SmolStr, ToSmolStr};
+23 -1
crates/jacquard-common/src/types/value.rs
··· 5 5 use bytes::Bytes; 6 6 use ipld_core::ipld::Ipld; 7 7 use smol_str::{SmolStr, ToSmolStr}; 8 - use std::collections::BTreeMap; 8 + use std::{collections::BTreeMap, convert::Infallible}; 9 9 10 10 /// Conversion utilities for Data types 11 11 pub mod convert; ··· 854 854 T: serde::Deserialize<'de> + IntoStatic, 855 855 { 856 856 T::deserialize(json).map(IntoStatic::into_static) 857 + } 858 + 859 + /// Deserialize a typed value from cbor bytes 860 + /// 861 + /// Returns an owned version, will allocate 862 + pub fn from_cbor<'de, T>( 863 + cbor: &'de [u8], 864 + ) -> Result<<T as IntoStatic>::Output, serde_ipld_dagcbor::DecodeError<Infallible>> 865 + where 866 + T: serde::Deserialize<'de> + IntoStatic, 867 + { 868 + serde_ipld_dagcbor::from_slice::<T>(cbor).map(|d| d.into_static()) 869 + } 870 + 871 + /// Deserialize a typed value from postcard bytes 872 + /// 873 + /// Returns an owned version, will allocate 874 + pub fn from_postcard<'de, T>(bytes: &'de [u8]) -> Result<<T as IntoStatic>::Output, postcard::Error> 875 + where 876 + T: serde::Deserialize<'de> + IntoStatic, 877 + { 878 + postcard::from_bytes::<T>(bytes).map(|d| d.into_static()) 857 879 } 858 880 859 881 /// Deserialize a typed value from a `RawData` value
+2 -2
crates/jacquard-identity/Cargo.toml
··· 15 15 [features] 16 16 dns = ["dep:hickory-resolver"] 17 17 tracing = ["dep:tracing"] 18 - streaming = ["jacquard-common/streaming", "dep:n0-future"] 18 + streaming = ["jacquard-common/streaming"] 19 19 cache = ["dep:mini-moka"] 20 20 21 21 [dependencies] ··· 36 36 serde_html_form.workspace = true 37 37 urlencoding.workspace = true 38 38 tracing = { workspace = true, optional = true } 39 - n0-future = { workspace = true, optional = true } 39 + n0-future.workspace = true 40 40 mini-moka = { version = "0.10", path = "../mini-moka-vendored", optional = true } 41 41 # mini-moka = { version = "0.10", optional = true } 42 42
+64 -12
crates/jacquard-identity/src/lib.rs
··· 390 390 self 391 391 } 392 392 393 + /// Set the HTTP request timeout. Pass `None` to disable timeout. 394 + pub fn with_request_timeout(mut self, timeout: Option<n0_future::time::Duration>) -> Self { 395 + self.opts.request_timeout = timeout; 396 + self 397 + } 398 + 393 399 #[cfg(feature = "cache")] 394 400 /// Enable caching with default configuration 395 401 pub fn with_cache(mut self) -> Self { ··· 849 855 } 850 856 851 857 impl HttpClient for JacquardResolver { 858 + type Error = IdentityError; 859 + 852 860 async fn send_http( 853 861 &self, 854 862 request: http::Request<Vec<u8>>, 855 863 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 856 - self.http.send_http(request).await 864 + match self.opts.request_timeout { 865 + Some(duration) => n0_future::time::timeout(duration, self.http.send_http(request)) 866 + .await 867 + .map_err(|_| IdentityError::timeout())? 868 + .map_err(IdentityError::transport), 869 + None => self 870 + .http 871 + .send_http(request) 872 + .await 873 + .map_err(IdentityError::transport), 874 + } 857 875 } 858 - 859 - type Error = reqwest::Error; 860 876 } 861 877 862 878 #[cfg(feature = "streaming")] 863 879 impl jacquard_common::http_client::HttpClientExt for JacquardResolver { 864 880 /// Send HTTP request and return streaming response 865 - fn send_http_streaming( 881 + async fn send_http_streaming( 866 882 &self, 867 883 request: http::Request<Vec<u8>>, 868 - ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> { 869 - self.http.send_http_streaming(request) 884 + ) -> Result<http::Response<ByteStream>, Self::Error> { 885 + match self.opts.request_timeout { 886 + Some(duration) => { 887 + n0_future::time::timeout(duration, self.http.send_http_streaming(request)) 888 + .await 889 + .map_err(|_| IdentityError::timeout())? 890 + .map_err(IdentityError::transport) 891 + } 892 + None => self 893 + .http 894 + .send_http_streaming(request) 895 + .await 896 + .map_err(IdentityError::transport), 897 + } 870 898 } 871 899 872 900 /// Send HTTP request with streaming body and receive streaming response 873 901 #[cfg(not(target_arch = "wasm32"))] 874 - fn send_http_bidirectional<S>( 902 + async fn send_http_bidirectional<S>( 875 903 &self, 876 904 parts: http::request::Parts, 877 905 body: S, 878 - ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 906 + ) -> Result<http::Response<ByteStream>, Self::Error> 879 907 where 880 908 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> 881 909 + Send 882 910 + 'static, 883 911 { 884 - self.http.send_http_bidirectional(parts, body) 912 + match self.opts.request_timeout { 913 + Some(duration) => { 914 + n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body)) 915 + .await 916 + .map_err(|_| IdentityError::timeout())? 917 + .map_err(IdentityError::transport) 918 + } 919 + None => self 920 + .http 921 + .send_http_bidirectional(parts, body) 922 + .await 923 + .map_err(IdentityError::transport), 924 + } 885 925 } 886 926 887 927 /// Send HTTP request with streaming body and receive streaming response (WASM) 888 928 #[cfg(target_arch = "wasm32")] 889 - fn send_http_bidirectional<S>( 929 + async fn send_http_bidirectional<S>( 890 930 &self, 891 931 parts: http::request::Parts, 892 932 body: S, 893 - ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 933 + ) -> Result<http::Response<ByteStream>, Self::Error> 894 934 where 895 935 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static, 896 936 { 897 - self.http.send_http_bidirectional(parts, body) 937 + match self.opts.request_timeout { 938 + Some(duration) => { 939 + n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body)) 940 + .await 941 + .map_err(|_| IdentityError::timeout())? 942 + .map_err(IdentityError::transport) 943 + } 944 + None => self 945 + .http 946 + .send_http_bidirectional(parts, body) 947 + .await 948 + .map_err(IdentityError::transport), 949 + } 898 950 } 899 951 } 900 952
+17
crates/jacquard-identity/src/resolver.rs
··· 20 20 use jacquard_common::types::uri::Uri; 21 21 use jacquard_common::types::value::{AtDataError, Data}; 22 22 use jacquard_common::{CowStr, IntoStatic, smol_str}; 23 + use n0_future::time::Duration; 23 24 use smol_str::SmolStr; 24 25 use std::collections::BTreeMap; 25 26 use std::marker::Sync; ··· 219 220 pub validate_doc_id: bool, 220 221 /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 221 222 pub public_fallback_for_handle: bool, 223 + /// HTTP request timeout. Default: 10 seconds. Set to None to disable. 224 + pub request_timeout: Option<Duration>, 222 225 } 223 226 224 227 impl Default for ResolverOptions { ··· 250 253 .did_order(did_order) 251 254 .validate_doc_id(true) 252 255 .public_fallback_for_handle(true) 256 + .request_timeout(Duration::from_secs(20)) 253 257 .build() 254 258 } 255 259 } ··· 538 542 )] 539 543 Transport, 540 544 545 + /// Request timeout 546 + #[error("request timed out")] 547 + #[diagnostic( 548 + code(jacquard::identity::timeout), 549 + help("the server took too long to respond") 550 + )] 551 + Timeout, 552 + 541 553 /// HTTP status error 542 554 #[error("HTTP {0}")] 543 555 #[diagnostic( ··· 651 663 /// Create a transport error 652 664 pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 653 665 Self::new(IdentityErrorKind::Transport, Some(Box::new(source))) 666 + } 667 + 668 + /// Create a timeout error 669 + pub fn timeout() -> Self { 670 + Self::new(IdentityErrorKind::Timeout, None) 654 671 } 655 672 656 673 /// Create an HTTP status error
+9 -1
crates/jacquard-lexicon/src/codegen/structs.rs
··· 323 323 field_name: &str, 324 324 field_type: &LexObjectProperty<'static>, 325 325 is_required: bool, 326 - is_builder: bool, 326 + _is_builder: bool, 327 327 ) -> Result<TokenStream> { 328 328 if field_name.is_empty() { 329 329 eprintln!( ··· 368 368 // Add serde(borrow) to all fields with lifetimes 369 369 if needs_lifetime { 370 370 attrs.push(quote! { #[serde(borrow)] }); 371 + } 372 + 373 + if matches!(field_type, LexObjectProperty::Bytes(_)) { 374 + if is_required { 375 + attrs.push(quote! { #[serde(with = "jacquard_common::serde_bytes_helper")] }); 376 + } else { 377 + attrs.push(quote! {#[serde(with = "jacquard_common::opt_serde_bytes_helper")] }); 378 + } 371 379 } 372 380 373 381 Ok(quote! {
+6 -1
crates/jacquard-lexicon/src/error.rs
··· 76 76 )] 77 77 Unsupported { 78 78 /// Description of the unsupported feature 79 + #[allow(unused)] 79 80 feature: String, 80 81 /// NSID of lexicon containing the feature 82 + #[allow(unused)] 81 83 lexicon_nsid: String, 82 84 /// Optional suggestion for workaround 85 + #[allow(unused)] 83 86 suggestion: Option<String>, 84 87 }, 85 88 ··· 87 90 #[error("Name collision: {name}")] 88 91 #[diagnostic( 89 92 code(lexicon::name_collision), 90 - help("Multiple types would generate the same Rust identifier. Module paths will disambiguate.") 93 + help( 94 + "Multiple types would generate the same Rust identifier. Module paths will disambiguate." 95 + ) 91 96 )] 92 97 NameCollision { 93 98 /// The colliding name
+23 -8
crates/jacquard-oauth/src/atproto.rs
··· 152 152 #[derive(serde::Serialize)] 153 153 struct Parameters<'a> { 154 154 #[serde(skip_serializing_if = "Option::is_none")] 155 - redirect_uri: Option<Vec<CowStr<'a>>>, 155 + redirect_uri: Option<Vec<Url>>, 156 156 #[serde(skip_serializing_if = "Option::is_none")] 157 157 scope: Option<CowStr<'a>>, 158 158 } 159 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 - }), 160 + redirect_uri: redirect_uris.clone(), 165 161 scope: scopes 166 162 .as_ref() 167 163 .map(|s| Scope::serialize_multiple(s.as_slice())), ··· 196 192 keyset: &Option<Keyset>, 197 193 ) -> Result<OAuthClientMetadata<'m>> { 198 194 // For non-loopback clients, require a keyset/JWKs. 199 - // let is_loopback = 200 - // metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost"); 195 + let is_loopback = 196 + metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost"); 197 + let application_type = if is_loopback { 198 + Some(CowStr::new_static("native")) 199 + } else { 200 + Some(CowStr::new_static("web")) 201 + }; 201 202 // if !is_loopback && keyset.is_none() { 202 203 // return Err(Error::EmptyJwks); 203 204 // } ··· 234 235 client_id: client_id.to_cowstr().into_static(), 235 236 client_uri, 236 237 redirect_uris, 238 + application_type, 237 239 token_endpoint_auth_method: Some(auth_method.into()), 238 240 grant_types: if keyset.is_some() { 239 241 Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()) 240 242 } else { 241 243 None 242 244 }, 245 + response_types: vec!["code".to_cowstr()], 243 246 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 244 247 dpop_bound_access_tokens: Some(true), 245 248 jwks_uri, ··· 287 290 CowStr::new_static("http://127.0.0.1"), 288 291 CowStr::new_static("http://[::1]"), 289 292 ], 293 + application_type: Some(CowStr::new_static("native")), 290 294 scope: Some(CowStr::new_static("atproto")), 291 295 grant_types: None, 296 + response_types: vec!["code".to_cowstr()], 292 297 token_endpoint_auth_method: Some(AuthMethod::None.into()), 293 298 dpop_bound_access_tokens: Some(true), 294 299 jwks_uri: None, ··· 333 338 scope: Some(CowStr::new_static( 334 339 "account:email atproto transition:generic" 335 340 )), 341 + application_type: Some(CowStr::new_static("native")), 336 342 grant_types: None, 343 + response_types: vec!["code".to_cowstr()], 337 344 token_endpoint_auth_method: Some(AuthMethod::None.into()), 338 345 dpop_bound_access_tokens: Some(true), 339 346 jwks_uri: None, ··· 365 372 client_id: CowStr::new_static( 366 373 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1" 367 374 ), 375 + application_type: Some(CowStr::new_static("native")), 368 376 client_uri: None, 369 377 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 370 378 scope: Some(CowStr::new_static("atproto")), 371 379 grant_types: None, 380 + response_types: vec!["code".to_cowstr()], 372 381 token_endpoint_auth_method: Some(AuthMethod::None.into()), 373 382 dpop_bound_access_tokens: Some(true), 374 383 jwks_uri: None, ··· 400 409 redirect_uris: vec![CowStr::new_static("http://127.0.0.1:8000")], 401 410 scope: Some(CowStr::new_static("atproto")), 402 411 grant_types: None, 412 + application_type: Some(CowStr::new_static("native")), 413 + response_types: vec!["code".to_cowstr()], 403 414 token_endpoint_auth_method: Some(AuthMethod::None.into()), 404 415 dpop_bound_access_tokens: Some(true), 405 416 jwks_uri: None, ··· 431 442 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 432 443 scope: Some(CowStr::new_static("atproto")), 433 444 grant_types: None, 445 + application_type: Some(CowStr::new_static("native")), 446 + response_types: vec!["code".to_cowstr()], 434 447 token_endpoint_auth_method: Some(AuthMethod::None.into()), 435 448 dpop_bound_access_tokens: Some(true), 436 449 jwks_uri: None, ··· 484 497 client_id: CowStr::new_static("https://example.com/client_metadata.json"), 485 498 client_uri: Some(CowStr::new_static("https://example.com")), 486 499 redirect_uris: vec![CowStr::new_static("https://example.com/callback")], 500 + application_type: Some(CowStr::new_static("web")), 487 501 scope: Some(CowStr::new_static("atproto")), 488 502 grant_types: Some(vec![CowStr::new_static("authorization_code")]), 489 503 token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), 490 504 dpop_bound_access_tokens: Some(true), 505 + response_types: vec!["code".to_cowstr()], 491 506 jwks_uri: None, 492 507 jwks: Some(keyset.public_jwks()), 493 508 token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
+11 -3
crates/jacquard-oauth/src/loopback.rs
··· 1 1 #![cfg(feature = "loopback")] 2 2 3 3 use crate::{ 4 + atproto::AtprotoClientMetadata, 4 5 authstore::ClientAuthStore, 5 6 client::OAuthClient, 6 7 dpop::DpopExt, ··· 121 122 )) 122 123 .unwrap(); 123 124 124 - let mut client_data = self.registry.client_data.clone(); 125 - // Ensure the redirect URI is set correctly for the loopback server 126 - client_data.config.redirect_uris = vec![redirect]; 125 + let scopes = if opts.scopes.is_empty() { 126 + Some(self.registry.client_data.config.scopes.clone()) 127 + } else { 128 + Some(opts.scopes.clone().into_static()) 129 + }; 130 + 131 + let client_data = crate::session::ClientData { 132 + keyset: self.registry.client_data.keyset.clone(), 133 + config: AtprotoClientMetadata::new_localhost(Some(vec![redirect.clone()]), scopes), 134 + }; 127 135 // Build client using store and resolver 128 136 let flow_client = OAuthClient::new_with_shared( 129 137 self.registry.store.clone(),
+19
crates/jacquard-oauth/src/request.rs
··· 302 302 pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self { 303 303 Self::new(RequestErrorKind::Atproto, Some(Box::new(source))) 304 304 } 305 + 306 + /// Returns true if this error indicates permanent auth failure 307 + /// (token revoked, refresh_token expired, etc.) 308 + /// 309 + /// When this returns true, the session should be cleared from storage 310 + /// rather than retried. 311 + pub fn is_permanent(&self) -> bool { 312 + match &self.kind { 313 + RequestErrorKind::NoRefreshToken => true, 314 + RequestErrorKind::HttpStatusWithBody { body, .. } => { 315 + body.get("error") 316 + .and_then(|e| e.as_str()) 317 + .is_some_and(|e| matches!(e, "invalid_grant" | "access_denied")) 318 + } 319 + _ => false, 320 + } 321 + } 305 322 } 306 323 307 324 // From impls for common error types ··· 939 956 redirect_uris: vec![CowStr::new_static("https://client/cb")], 940 957 scope: Some(CowStr::from("atproto")), 941 958 grant_types: None, 959 + response_types: vec![CowStr::new_static("code")], 960 + application_type: Some(CowStr::new_static("web")), 942 961 token_endpoint_auth_method: Some(CowStr::from("none")), 943 962 dpop_bound_access_tokens: None, 944 963 jwks_uri: None,
+1
crates/jacquard-oauth/src/resolver.rs
··· 5 5 use http::{Request, StatusCode}; 6 6 use jacquard_common::CowStr; 7 7 use jacquard_common::IntoStatic; 8 + #[allow(unused_imports)] 8 9 use jacquard_common::cowstr::ToCowStr; 9 10 use jacquard_common::types::did_doc::DidDocument; 10 11 use jacquard_common::types::ident::AtIdentifier;
+45 -6
crates/jacquard-oauth/src/session.rs
··· 1 1 use std::sync::Arc; 2 2 3 + use chrono::TimeDelta; 4 + 3 5 use crate::{ 4 6 atproto::{AtprotoClientMetadata, atproto_client_metadata}, 5 7 authstore::ClientAuthStore, ··· 295 297 #[error("session does not exist")] 296 298 #[diagnostic(code(jacquard_oauth::session::not_found))] 297 299 SessionNotFound, 300 + #[error("session refresh failed permanently")] 301 + #[diagnostic( 302 + code(jacquard_oauth::session::refresh_failed), 303 + help("the session has been cleared - user must re-authenticate") 304 + )] 305 + RefreshFailed(#[source] crate::request::RequestError), 306 + } 307 + 308 + impl Error { 309 + /// Returns true if this error indicates a permanent auth failure 310 + /// where the user needs to re-authenticate. 311 + pub fn is_permanent(&self) -> bool { 312 + match self { 313 + Error::RefreshFailed(_) => true, 314 + Error::SessionNotFound => true, 315 + Error::ServerAgent(e) => e.is_permanent(), 316 + Error::Store(_) => false, 317 + } 318 + } 298 319 } 299 320 300 321 pub struct SessionRegistry<T, S> ··· 351 372 .clone(); 352 373 let _guard = lock.lock().await; 353 374 354 - let mut session = self 375 + let session = self 355 376 .store 356 377 .get_session(did, session_id) 357 378 .await? 358 379 .ok_or(Error::SessionNotFound)?; 380 + 381 + // Check if token is still valid with a 60-second buffer before expiry. 382 + // This triggers proactive refresh before the token actually expires, 383 + // avoiding the race condition where a token expires mid-request. 384 + const EXPIRY_BUFFER_SECS: i64 = 60; 359 385 if let Some(expires_at) = &session.token_set.expires_at { 360 - if expires_at > &Datetime::now() { 386 + let now_with_buffer = Datetime::now() 387 + .as_ref() 388 + .checked_add_signed(TimeDelta::seconds(EXPIRY_BUFFER_SECS)) 389 + .map(Datetime::new) 390 + .unwrap_or_else(Datetime::now); 391 + if expires_at > &now_with_buffer { 361 392 return Ok(session); 362 393 } 363 394 } 364 395 let metadata = 365 396 OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?; 366 - session = refresh(self.client.as_ref(), session, &metadata).await?; 367 - self.store.upsert_session(session.clone()).await?; 368 - 369 - Ok(session) 397 + match refresh(self.client.as_ref(), session, &metadata).await { 398 + Ok(refreshed) => { 399 + self.store.upsert_session(refreshed.clone()).await?; 400 + Ok(refreshed) 401 + } 402 + Err(e) if e.is_permanent() => { 403 + // Session is permanently dead - clean it up 404 + let _ = self.store.delete_session(did, session_id).await; 405 + Err(Error::RefreshFailed(e)) 406 + } 407 + Err(e) => Err(Error::ServerAgent(e)), 408 + } 370 409 } 371 410 pub async fn get( 372 411 &self,
+1 -1
crates/jacquard-oauth/src/types.rs
··· 47 47 fn default() -> Self { 48 48 Self { 49 49 redirect_uri: None, 50 - scopes: vec![Scope::Atproto], 50 + scopes: vec![], 51 51 prompt: None, 52 52 state: None, 53 53 }
+5
crates/jacquard-oauth/src/types/client_metadata.rs
··· 13 13 #[serde(borrow)] 14 14 pub scope: Option<CowStr<'c>>, 15 15 #[serde(skip_serializing_if = "Option::is_none")] 16 + pub application_type: Option<CowStr<'c>>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 16 18 pub grant_types: Option<Vec<CowStr<'c>>>, 17 19 #[serde(skip_serializing_if = "Option::is_none")] 18 20 pub token_endpoint_auth_method: Option<CowStr<'c>>, 21 + pub response_types: Vec<CowStr<'c>>, 19 22 // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 20 23 #[serde(skip_serializing_if = "Option::is_none")] 21 24 pub dpop_bound_access_tokens: Option<bool>, ··· 48 51 client_uri: self.client_uri.into_static(), 49 52 redirect_uris: self.redirect_uris.into_static(), 50 53 scope: self.scope.map(|scope| scope.into_static()), 54 + application_type: self.application_type.map(|app_type| app_type.into_static()), 51 55 grant_types: self.grant_types.map(|types| types.into_static()), 56 + response_types: self.response_types.into_static(), 52 57 token_endpoint_auth_method: self 53 58 .token_endpoint_auth_method 54 59 .map(|method| method.into_static()),
+2 -1
crates/jacquard/Cargo.toml
··· 12 12 license.workspace = true 13 13 14 14 [features] 15 - default = ["api_full", "dns", "loopback", "derive"] 15 + default = ["api_full", "dns", "loopback", "derive", "cache"] 16 16 derive = ["dep:jacquard-derive"] 17 17 # Minimal API bindings 18 18 api = ["jacquard-api/minimal"] ··· 44 44 "dep:n0-future", 45 45 "jacquard-api/streaming", 46 46 ] 47 + cache = ["jacquard-identity/cache"] 47 48 websocket = ["jacquard-common/websocket"] 48 49 zstd = ["jacquard-common/zstd"] 49 50