A better Rust ATProto crate

serde bytes helpers for bytes fields in json

Orual 76e7ad63 cbd55399

Changed files
+363 -40
crates
+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