A better Rust ATProto crate

wasm support!

Orual 972bd849 cae701f4

+20
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## [0.5.3] - 2025-10-15 4 + 5 + ### Added 6 + 7 + **Experimental WASM Support** (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) 8 + - Core crates now compile for `wasm32-unknown-unknown` target 9 + - Traits use `trait-variant` to conditionally exclude `Send` bounds on WASM 10 + - Platform-specific trait method implementations for methods with `Self: Sync` bounds 11 + - DNS-based handle resolution remains gated behind `dns` feature (unavailable on WASM) 12 + - HTTPS well-known and PDS resolution work on all platforms 13 + 14 + ### Fixed 15 + 16 + **OAuth client** (`jacquard-oauth`) 17 + - Fixed tokio runtime detection for non-WASM targets 18 + - Conditional compilation for tokio-specific features 19 + 20 + 21 + --- 22 + 3 23 ## [0.5.2] - 2025-10-14 4 24 5 25 ### Added
+23 -31
Cargo.lock
··· 1176 1176 "cfg-if", 1177 1177 "js-sys", 1178 1178 "libc", 1179 - "wasi 0.11.1+wasi-snapshot-preview1", 1179 + "wasi", 1180 1180 "wasm-bindgen", 1181 1181 ] 1182 1182 1183 1183 [[package]] 1184 1184 name = "getrandom" 1185 - version = "0.3.3" 1185 + version = "0.3.4" 1186 1186 source = "registry+https://github.com/rust-lang/crates.io-index" 1187 - checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 1187 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 1188 1188 dependencies = [ 1189 1189 "cfg-if", 1190 1190 "js-sys", 1191 1191 "libc", 1192 1192 "r-efi", 1193 - "wasi 0.14.7+wasi-0.2.4", 1193 + "wasip2", 1194 1194 "wasm-bindgen", 1195 1195 ] 1196 1196 ··· 1470 1470 "libc", 1471 1471 "percent-encoding", 1472 1472 "pin-project-lite", 1473 - "socket2 0.5.10", 1473 + "socket2 0.6.0", 1474 1474 "system-configuration", 1475 1475 "tokio", 1476 1476 "tower-service", ··· 1732 1732 1733 1733 [[package]] 1734 1734 name = "jacquard" 1735 - version = "0.5.2" 1735 + version = "0.5.3" 1736 1736 dependencies = [ 1737 - "async-trait", 1738 1737 "bon", 1739 1738 "bytes", 1740 1739 "clap", 1740 + "getrandom 0.2.16", 1741 1741 "http", 1742 1742 "jacquard-api 0.5.2", 1743 1743 "jacquard-common 0.5.2", 1744 - "jacquard-derive 0.5.2", 1744 + "jacquard-derive 0.5.3", 1745 1745 "jacquard-identity 0.5.2", 1746 1746 "jacquard-oauth", 1747 1747 "jose-jwk", ··· 1758 1758 "thiserror 2.0.17", 1759 1759 "tokio", 1760 1760 "tracing", 1761 + "trait-variant", 1761 1762 "url", 1762 1763 ] 1763 1764 ··· 1782 1783 "bon", 1783 1784 "bytes", 1784 1785 "jacquard-common 0.5.2", 1785 - "jacquard-derive 0.5.2", 1786 + "jacquard-derive 0.5.3", 1786 1787 "miette", 1787 1788 "serde", 1788 1789 "thiserror 2.0.17", ··· 1800 1801 "chrono", 1801 1802 "jacquard", 1802 1803 "jacquard-common 0.5.2", 1803 - "jacquard-derive 0.5.2", 1804 + "jacquard-derive 0.5.3", 1804 1805 "jacquard-identity 0.5.2", 1805 1806 "k256", 1806 1807 "miette", ··· 1856 1857 name = "jacquard-common" 1857 1858 version = "0.5.2" 1858 1859 dependencies = [ 1859 - "async-trait", 1860 1860 "base64 0.22.1", 1861 1861 "bon", 1862 1862 "bytes", 1863 1863 "chrono", 1864 1864 "cid", 1865 1865 "ed25519-dalek", 1866 + "getrandom 0.3.4", 1866 1867 "http", 1867 1868 "ipld-core", 1868 1869 "k256", ··· 1884 1885 "thiserror 2.0.17", 1885 1886 "tokio", 1886 1887 "tracing", 1888 + "trait-variant", 1887 1889 "url", 1888 1890 ] 1889 1891 ··· 1906 1908 1907 1909 [[package]] 1908 1910 name = "jacquard-derive" 1909 - version = "0.5.2" 1911 + version = "0.5.3" 1910 1912 dependencies = [ 1911 1913 "jacquard-common 0.5.2", 1912 1914 "proc-macro2", ··· 1943 1945 name = "jacquard-identity" 1944 1946 version = "0.5.2" 1945 1947 dependencies = [ 1946 - "async-trait", 1947 1948 "bon", 1948 1949 "bytes", 1949 1950 "hickory-resolver", ··· 1959 1960 "thiserror 2.0.17", 1960 1961 "tokio", 1961 1962 "tracing", 1963 + "trait-variant", 1962 1964 "url", 1963 1965 "urlencoding", 1964 1966 ] 1965 1967 1966 1968 [[package]] 1967 1969 name = "jacquard-lexicon" 1968 - version = "0.5.2" 1970 + version = "0.5.3" 1969 1971 dependencies = [ 1970 1972 "async-trait", 1971 1973 "clap", ··· 1995 1997 name = "jacquard-oauth" 1996 1998 version = "0.5.2" 1997 1999 dependencies = [ 1998 - "async-trait", 1999 2000 "base64 0.22.1", 2000 2001 "bytes", 2001 2002 "chrono", ··· 2308 2309 checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 2309 2310 dependencies = [ 2310 2311 "libc", 2311 - "wasi 0.11.1+wasi-snapshot-preview1", 2312 + "wasi", 2312 2313 "windows-sys 0.59.0", 2313 2314 ] 2314 2315 ··· 2764 2765 "quinn-udp", 2765 2766 "rustc-hash", 2766 2767 "rustls", 2767 - "socket2 0.5.10", 2768 + "socket2 0.6.0", 2768 2769 "thiserror 2.0.17", 2769 2770 "tokio", 2770 2771 "tracing", ··· 2778 2779 checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 2779 2780 dependencies = [ 2780 2781 "bytes", 2781 - "getrandom 0.3.3", 2782 + "getrandom 0.3.4", 2782 2783 "lru-slab", 2783 2784 "rand 0.9.2", 2784 2785 "ring", ··· 2801 2802 "cfg_aliases", 2802 2803 "libc", 2803 2804 "once_cell", 2804 - "socket2 0.5.10", 2805 + "socket2 0.6.0", 2805 2806 "tracing", 2806 2807 "windows-sys 0.60.2", 2807 2808 ] ··· 2877 2878 source = "registry+https://github.com/rust-lang/crates.io-index" 2878 2879 checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 2879 2880 dependencies = [ 2880 - "getrandom 0.3.3", 2881 + "getrandom 0.3.4", 2881 2882 ] 2882 2883 2883 2884 [[package]] ··· 3624 3625 checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 3625 3626 dependencies = [ 3626 3627 "fastrand", 3627 - "getrandom 0.3.3", 3628 + "getrandom 0.3.4", 3628 3629 "once_cell", 3629 3630 "rustix", 3630 3631 "windows-sys 0.60.2", ··· 4124 4125 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 4125 4126 4126 4127 [[package]] 4127 - name = "wasi" 4128 - version = "0.14.7+wasi-0.2.4" 4129 - source = "registry+https://github.com/rust-lang/crates.io-index" 4130 - checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" 4131 - dependencies = [ 4132 - "wasip2", 4133 - ] 4134 - 4135 - [[package]] 4136 4128 name = "wasip2" 4137 4129 version = "1.0.1+wasi-0.2.4" 4138 4130 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4271 4263 source = "registry+https://github.com/rust-lang/crates.io-index" 4272 4264 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 4273 4265 dependencies = [ 4274 - "windows-sys 0.48.0", 4266 + "windows-sys 0.60.2", 4275 4267 ] 4276 4268 4277 4269 [[package]]
+2 -3
Cargo.toml
··· 5 5 6 6 [workspace.package] 7 7 edition = "2024" 8 - version = "0.5.2" 8 + version = "0.5.3" 9 9 authors = ["Orual <orual@nonbinary.computer>"] 10 10 repository = "https://tangled.org/@nonbinary.computer/jacquard" 11 11 keywords = ["atproto", "at", "bluesky", "api", "client"] ··· 59 59 reqwest = { version = "0.12", default-features = false } 60 60 61 61 # Async and runtimes 62 - async-trait = "0.1" 63 - tokio = "1" 62 + tokio = { version = "1", default-features = false } 64 63 65 64 # Observability 66 65 tracing = "0.1"
+13 -3
README.md
··· 104 104 105 105 Highlights: 106 106 107 + - experimental WASM support 107 108 - better value type deserialization helpers 108 109 - service auth implementation 109 110 - XrpcRequest derive Macros 110 111 - more builders in generated api to make constructing things easier (lmk if compile time is awful) 111 112 - `AgentSessionExt` trait with a host of convenience methods for working with records and preferences 112 113 - Improvements to the `Collection` trait, code generation, and addition of the `VecUpdate` trait to enable that 113 - - A bunch of examples, both in the docs and in the repository 114 - - More lexicons in the generated API bindings. 115 114 116 115 ## Development 117 116 118 - This repo uses [Flakes](https://nixos.asia/en/flakes) from the get-go. 117 + This repo uses [Flakes](https://nixos.asia/en/flakes) 119 118 120 119 ```bash 121 120 # Dev shell ··· 129 128 ``` 130 129 131 130 There's also a [`justfile`](https://just.systems/) for Makefile-esque commands to be run inside of the devShell, and you can generally `cargo ...` or `just ...` whatever just fine if you don't want to use Nix and have the prerequisites installed. 131 + 132 + 133 + ## Experimental WASM Support 134 + 135 + Core crates (`jacquard-common`, `jacquard-api`, `jacquard-identity`, `jacquard-oauth`) compile for `wasm32-unknown-unknown`. Traits use [`trait-variant`](https://docs.rs/trait-variant) to conditionally exclude `Send` bounds on WASM targets. DNS-based handle resolution is gated behind the `dns` feature and unavailable on WASM (HTTPS well-known and PDS resolution still work). 136 + 137 + Test WASM compilation: 138 + ```bash 139 + just check-wasm 140 + # or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 141 + ``` 132 142 133 143 [![License](https://img.shields.io/crates/l/jacquard.svg)](./LICENSE)
+10 -3
crates/jacquard-common/Cargo.toml
··· 13 13 14 14 15 15 [dependencies] 16 + trait-variant.workspace = true 16 17 bon.workspace = true 17 18 base64.workspace = true 18 19 bytes.workspace = true ··· 33 34 thiserror.workspace = true 34 35 url.workspace = true 35 36 http.workspace = true 36 - async-trait.workspace = true 37 - tokio = { workspace = true, features = ["sync"] } 38 - reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 37 + 38 + reqwest = { workspace = true, optional = true, features = ["charset", "gzip"] } 39 39 serde_ipld_dagcbor.workspace = true 40 40 signature = { version = "2", optional = true } 41 41 tracing = { workspace = true, optional = true } 42 + tokio = { workspace = true, default-features = false, features = ["sync"] } 43 + 44 + [target.'cfg(target_family = "wasm")'.dependencies] 45 + getrandom = { version = "0.3.4", features = ["wasm_js"] } 46 + 47 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 48 + reqwest = { workspace = true, optional = true, features = ["http2", "system-proxy", "rustls-tls"] } 42 49 43 50 [features] 44 51 default = ["service-auth", "reqwest-client", "crypto"]
+11
crates/jacquard-common/src/error.rs
··· 119 119 120 120 #[cfg(feature = "reqwest-client")] 121 121 impl From<reqwest::Error> for TransportError { 122 + #[cfg(not(target_arch = "wasm32"))] 122 123 fn from(e: reqwest::Error) -> Self { 123 124 if e.is_timeout() { 124 125 Self::Timeout 125 126 } else if e.is_connect() { 126 127 Self::Connect(e.to_string()) 128 + } else if e.is_builder() || e.is_request() { 129 + Self::InvalidRequest(e.to_string()) 130 + } else { 131 + Self::Other(Box::new(e)) 132 + } 133 + } 134 + #[cfg(target_arch = "wasm32")] 135 + fn from(e: reqwest::Error) -> Self { 136 + if e.is_timeout() { 137 + Self::Timeout 127 138 } else if e.is_builder() || e.is_request() { 128 139 Self::InvalidRequest(e.to_string()) 129 140 } else {
+17 -2
crates/jacquard-common/src/http_client.rs
··· 5 5 use std::sync::Arc; 6 6 7 7 /// HTTP client trait for sending raw HTTP requests. 8 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 8 9 pub trait HttpClient { 9 10 /// Error type returned by the HTTP client 10 11 type Error: std::error::Error + Display + Send + Sync + 'static; 12 + 11 13 /// Send an HTTP request and return the response. 12 14 fn send_http( 13 15 &self, 14 16 request: http::Request<Vec<u8>>, 15 - ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send; 17 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>>; 16 18 } 17 19 18 20 #[cfg(feature = "reqwest-client")] ··· 51 53 } 52 54 } 53 55 54 - impl<T: HttpClient> HttpClient for Arc<T> { 56 + #[cfg(not(target_arch = "wasm32"))] 57 + impl<T: HttpClient + Sync> HttpClient for Arc<T> { 55 58 type Error = T::Error; 56 59 57 60 fn send_http( ··· 62 65 self.as_ref().send_http(request) 63 66 } 64 67 } 68 + 69 + #[cfg(target_arch = "wasm32")] 70 + impl<T: HttpClient> HttpClient for Arc<T> { 71 + type Error = T::Error; 72 + 73 + fn send_http( 74 + &self, 75 + request: http::Request<Vec<u8>>, 76 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 77 + self.as_ref().send_http(request) 78 + } 79 + }
+5 -7
crates/jacquard-common/src/session.rs
··· 1 1 //! Generic session storage traits and utilities. 2 2 3 - use async_trait::async_trait; 4 3 use miette::Diagnostic; 5 4 use serde::Serialize; 6 5 use serde::de::DeserializeOwned; ··· 8 7 use std::collections::HashMap; 9 8 use std::error::Error as StdError; 10 9 use std::fmt::Display; 10 + use std::future::Future; 11 11 use std::hash::Hash; 12 12 use std::path::{Path, PathBuf}; 13 13 use std::sync::Arc; ··· 31 31 } 32 32 33 33 /// Pluggable storage for arbitrary session records. 34 - #[async_trait] 34 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 35 35 pub trait SessionStore<K, T>: Send + Sync 36 36 where 37 37 K: Eq + Hash, 38 38 T: Clone, 39 39 { 40 40 /// Get the current session if present. 41 - async fn get(&self, key: &K) -> Option<T>; 41 + fn get(&self, key: &K) -> impl Future<Output = Option<T>>; 42 42 /// Persist the given session. 43 - async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>; 43 + fn set(&self, key: K, session: T) -> impl Future<Output = Result<(), SessionStoreError>>; 44 44 /// Delete the given session. 45 - async fn del(&self, key: &K) -> Result<(), SessionStoreError>; 45 + fn del(&self, key: &K) -> impl Future<Output = Result<(), SessionStoreError>>; 46 46 } 47 47 48 48 /// In-memory session store suitable for short-lived sessions and tests. ··· 55 55 } 56 56 } 57 57 58 - #[async_trait] 59 58 impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T> 60 59 where 61 60 K: Eq + Hash + Send + Sync, ··· 103 102 } 104 103 } 105 104 106 - #[async_trait::async_trait] 107 105 impl< 108 106 K: Eq + Hash + Display + Send + Sync + 'static, 109 107 T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
+6 -6
crates/jacquard-common/src/types/tid.rs
··· 119 119 } 120 120 121 121 /// Construct a TID from a timestamp (in microseconds) and clock ID 122 - pub fn from_time(timestamp: usize, clkid: u32) -> Self { 122 + pub fn from_time(timestamp: u64, clkid: u32) -> Self { 123 123 let str = smol_str::format_smolstr!( 124 124 "{0}{1:2>2}", 125 125 s32_encode(timestamp as u64), ··· 129 129 } 130 130 131 131 /// Extract the timestamp component (microseconds since UNIX epoch) 132 - pub fn timestamp(&self) -> usize { 132 + pub fn timestamp(&self) -> u64 { 133 133 s32decode(self.0[0..11].to_owned()) 134 134 } 135 135 ··· 194 194 } 195 195 196 196 /// Decode a base32-sortable string into a usize 197 - pub fn s32decode(s: String) -> usize { 197 + pub fn s32decode(s: String) -> u64 { 198 198 let mut i: usize = 0; 199 199 for c in s.chars() { 200 200 i = i * 32 + S32_CHAR.chars().position(|x| x == c).unwrap(); 201 201 } 202 - i 202 + i as u64 203 203 } 204 204 205 205 impl FromStr for Tid { ··· 289 289 /// Based on adenosine/adenosine/src/identifiers.rs 290 290 /// TODO: clean up and normalize stuff between this and the stuff pulled from atrium 291 291 pub struct Ticker { 292 - last_timestamp: usize, 292 + last_timestamp: u64, 293 293 clock_id: u32, 294 294 } 295 295 ··· 311 311 let now = SystemTime::now() 312 312 .duration_since(SystemTime::UNIX_EPOCH) 313 313 .expect("timestamp in micros since UNIX epoch") 314 - .as_micros() as usize; 314 + .as_micros() as u64; 315 315 // mask to 53 bits 316 316 let now = now & 0x001FFFFFFFFFFFFF; 317 317 if now > self.last_timestamp {
+16 -2
crates/jacquard-common/src/xrpc.rs
··· 226 226 impl<T: HttpClient> XrpcExt for T {} 227 227 228 228 /// Stateful XRPC call trait 229 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 229 230 pub trait XrpcClient: HttpClient { 230 231 /// Get the base URI for the client. 231 232 fn base_uri(&self) -> Url; ··· 234 235 fn opts(&self) -> impl Future<Output = CallOptions<'_>> { 235 236 async { CallOptions::default() } 236 237 } 238 + 237 239 /// Send an XRPC request and parse the response 240 + #[cfg(not(target_arch = "wasm32"))] 238 241 fn send<R>( 239 242 &self, 240 243 request: R, 241 - ) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>> + Send 244 + ) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>> 245 + where 246 + R: XrpcRequest + Send + Sync, 247 + <R as XrpcRequest>::Response: Send + Sync, 248 + Self: Sync; 249 + 250 + /// Send an XRPC request and parse the response 251 + #[cfg(target_arch = "wasm32")] 252 + fn send<R>( 253 + &self, 254 + request: R, 255 + ) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>> 242 256 where 243 257 R: XrpcRequest + Send + Sync, 244 258 <R as XrpcRequest>::Response: Send + Sync; ··· 308 322 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self, request), fields(nsid = R::NSID)))] 309 323 pub async fn send<R>(self, request: &R) -> XrpcResult<Response<<R as XrpcRequest>::Response>> 310 324 where 311 - R: XrpcRequest + Send + Sync, 325 + R: XrpcRequest, 312 326 <R as XrpcRequest>::Response: Send + Sync, 313 327 { 314 328 let http_request = build_http_request(&self.base, request, &self.opts)
+6 -3
crates/jacquard-identity/Cargo.toml
··· 17 17 tracing = ["dep:tracing"] 18 18 19 19 [dependencies] 20 - async-trait.workspace = true 20 + trait-variant.workspace = true 21 21 bon.workspace = true 22 22 bytes.workspace = true 23 23 jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } ··· 25 25 percent-encoding.workspace = true 26 26 reqwest.workspace = true 27 27 url.workspace = true 28 - tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } 29 - hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]} 30 28 serde.workspace = true 31 29 serde_json.workspace = true 32 30 thiserror.workspace = true ··· 35 33 serde_html_form.workspace = true 36 34 urlencoding.workspace = true 37 35 tracing = { workspace = true, optional = true } 36 + 37 + 38 + [target.'cfg(not(target_family = "wasm"))'.dependencies] 39 + hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]} 40 + tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+4 -3
crates/jacquard-identity/src/lib.rs
··· 66 66 //! Both support `.parse()` for borrowing and validation. 67 67 68 68 // use crate::CowStr; // not currently needed directly here 69 + 70 + #![cfg_attr(target_arch = "wasm32", allow(unused))] 69 71 pub mod resolver; 70 72 71 73 use crate::resolver::{ ··· 84 86 use jacquard_common::{IntoStatic, types::string::Handle}; 85 87 use percent_encoding::percent_decode_str; 86 88 use reqwest::StatusCode; 87 - use std::sync::Arc; 88 89 use url::{ParseError, Url}; 89 90 90 - #[cfg(feature = "dns")] 91 - use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 91 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 92 + use {hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}, std::sync::Arc}; 92 93 93 94 /// Default resolver implementation with configurable fallback order. 94 95 #[derive(Clone)]
+136 -18
crates/jacquard-identity/src/resolver.rs
··· 8 8 //! 9 9 //! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer 10 10 //! and optionally validate the document `id` against the requested DID. 11 - 12 - use std::collections::BTreeMap; 13 - use std::marker::Sync; 14 - use std::str::FromStr; 15 - 11 + #![cfg_attr(target_arch = "wasm32", allow(unused))] 16 12 use bon::Builder; 17 13 use bytes::Bytes; 18 14 use http::StatusCode; ··· 25 21 use jacquard_common::types::value::{AtDataError, Data}; 26 22 use jacquard_common::{CowStr, IntoStatic}; 27 23 use miette::Diagnostic; 24 + use std::collections::BTreeMap; 25 + use std::marker::Sync; 26 + use std::str::FromStr; 28 27 use thiserror::Error; 29 28 use url::Url; 30 29 ··· 73 72 #[diagnostic(code(jacquard_identity::url))] 74 73 Url(#[from] url::ParseError), 75 74 #[error("DNS error: {0}")] 76 - #[cfg(feature = "dns")] 75 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 77 76 #[diagnostic(code(jacquard_identity::dns))] 78 77 Dns(#[from] hickory_resolver::error::ResolveError), 79 78 #[error("serialize/deserialize error: {0}")] ··· 299 298 fn default() -> Self { 300 299 // By default, prefer DNS then HTTPS for handles, then PDS fallback 301 300 // For DID documents, prefer method-native sources, then PDS fallback 301 + let mut handle_order = vec![]; 302 + #[cfg(not(target_family = "wasm"))] 303 + handle_order.push(HandleStep::DnsTxt); 304 + handle_order.push(HandleStep::HttpsWellKnown); 305 + handle_order.push(HandleStep::PdsResolveHandle); 306 + 302 307 Self::new() 303 308 .plc_source(PlcSource::default()) 304 - .handle_order(vec![ 305 - HandleStep::DnsTxt, 306 - HandleStep::HttpsWellKnown, 307 - HandleStep::PdsResolveHandle, 308 - ]) 309 + .handle_order(handle_order) 309 310 .did_order(vec![ 310 311 DidStep::DidWebHttps, 311 312 DidStep::PlcHttp, ··· 326 327 /// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 327 328 /// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 328 329 330 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 329 331 pub trait IdentityResolver { 330 332 /// Access options for validation decisions in default methods 331 333 fn options(&self) -> &ResolverOptions; 332 334 333 335 /// Resolve handle 336 + #[cfg(not(target_arch = "wasm32"))] 334 337 fn resolve_handle( 335 338 &self, 336 339 handle: &Handle<'_>, 337 - ) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send 340 + ) -> impl Future<Output = Result<Did<'static>, IdentityError>> 338 341 where 339 342 Self: Sync; 340 343 344 + /// Resolve handle 345 + #[cfg(target_arch = "wasm32")] 346 + fn resolve_handle( 347 + &self, 348 + handle: &Handle<'_>, 349 + ) -> impl Future<Output = Result<Did<'static>, IdentityError>>; 350 + 341 351 /// Resolve DID document 352 + #[cfg(not(target_arch = "wasm32"))] 342 353 fn resolve_did_doc( 343 354 &self, 344 355 did: &Did<'_>, 345 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send 356 + ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> 346 357 where 347 358 Self: Sync; 359 + 360 + /// Resolve DID document 361 + #[cfg(target_arch = "wasm32")] 362 + fn resolve_did_doc( 363 + &self, 364 + did: &Did<'_>, 365 + ) -> impl Future<Output = Result<DidDocResponse, IdentityError>>; 348 366 349 367 /// Resolve DID doc from an identifier 368 + #[cfg(not(target_arch = "wasm32"))] 350 369 fn resolve_ident( 351 370 &self, 352 371 actor: &AtIdentifier<'_>, 353 - ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send 372 + ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> 354 373 where 355 374 Self: Sync, 356 375 { ··· 366 385 } 367 386 368 387 /// Resolve DID doc from an identifier 388 + #[cfg(target_arch = "wasm32")] 389 + fn resolve_ident( 390 + &self, 391 + actor: &AtIdentifier<'_>, 392 + ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> { 393 + async move { 394 + match actor { 395 + AtIdentifier::Did(did) => self.resolve_did_doc(&did).await, 396 + AtIdentifier::Handle(handle) => { 397 + let did = self.resolve_handle(&handle).await?; 398 + self.resolve_did_doc(&did).await 399 + } 400 + } 401 + } 402 + } 403 + 404 + /// Resolve DID doc from an identifier 405 + #[cfg(not(target_arch = "wasm32"))] 369 406 fn resolve_ident_owned( 370 407 &self, 371 408 actor: &AtIdentifier<'_>, 372 - ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send 409 + ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> 373 410 where 374 411 Self: Sync, 375 412 { ··· 384 421 } 385 422 } 386 423 424 + /// Resolve DID doc from an identifier 425 + #[cfg(target_arch = "wasm32")] 426 + fn resolve_ident_owned( 427 + &self, 428 + actor: &AtIdentifier<'_>, 429 + ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> { 430 + async move { 431 + match actor { 432 + AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await, 433 + AtIdentifier::Handle(handle) => { 434 + let did = self.resolve_handle(&handle).await?; 435 + self.resolve_did_doc_owned(&did).await 436 + } 437 + } 438 + } 439 + } 440 + 387 441 /// Resolve the DID document and return an owned version 442 + #[cfg(not(target_arch = "wasm32"))] 388 443 fn resolve_did_doc_owned( 389 444 &self, 390 445 did: &Did<'_>, 391 - ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send 446 + ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> 392 447 where 393 448 Self: Sync, 394 449 { 395 450 async { self.resolve_did_doc(did).await?.into_owned() } 396 451 } 452 + 453 + /// Resolve the DID document and return an owned version 454 + #[cfg(target_arch = "wasm32")] 455 + fn resolve_did_doc_owned( 456 + &self, 457 + did: &Did<'_>, 458 + ) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> { 459 + async { self.resolve_did_doc(did).await?.into_owned() } 460 + } 461 + 397 462 /// Return the PDS url for a DID 398 - fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> + Send 463 + #[cfg(not(target_arch = "wasm32"))] 464 + fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> 399 465 where 400 466 Self: Sync, 401 467 { ··· 414 480 doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 415 481 } 416 482 } 483 + 484 + /// Return the PDS url for a DID 485 + #[cfg(target_arch = "wasm32")] 486 + fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> { 487 + async { 488 + let resp = self.resolve_did_doc(did).await?; 489 + let doc = resp.parse()?; 490 + // Default-on doc id equality check 491 + if self.options().validate_doc_id { 492 + if doc.id.as_str() != did.as_str() { 493 + return Err(IdentityError::DocIdMismatch { 494 + expected: did.clone().into_static(), 495 + doc: doc.clone().into_static(), 496 + }); 497 + } 498 + } 499 + doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint) 500 + } 501 + } 502 + 417 503 /// Return the DIS and PDS url for a handle 504 + #[cfg(not(target_arch = "wasm32"))] 418 505 fn pds_for_handle( 419 506 &self, 420 507 handle: &Handle<'_>, 421 - ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> + Send 508 + ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> 422 509 where 423 510 Self: Sync, 424 511 { ··· 428 515 Ok((did, pds)) 429 516 } 430 517 } 518 + 519 + /// Return the DIS and PDS url for a handle 520 + #[cfg(target_arch = "wasm32")] 521 + fn pds_for_handle( 522 + &self, 523 + handle: &Handle<'_>, 524 + ) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> { 525 + async { 526 + let did = self.resolve_handle(handle).await?; 527 + let pds = self.pds_for_did(&did).await?; 528 + Ok((did, pds)) 529 + } 530 + } 431 531 } 432 532 533 + #[cfg(not(target_arch = "wasm32"))] 433 534 impl<T: IdentityResolver + Sync> IdentityResolver for std::sync::Arc<T> { 535 + fn options(&self) -> &ResolverOptions { 536 + self.as_ref().options() 537 + } 538 + 539 + /// Resolve handle 540 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 541 + self.as_ref().resolve_handle(handle).await 542 + } 543 + 544 + /// Resolve DID document 545 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 546 + self.as_ref().resolve_did_doc(did).await 547 + } 548 + } 549 + 550 + #[cfg(target_arch = "wasm32")] 551 + impl<T: IdentityResolver> IdentityResolver for std::sync::Arc<T> { 434 552 fn options(&self) -> &ResolverOptions { 435 553 self.as_ref().options() 436 554 }
+2 -3
crates/jacquard-lexicon/src/fetch/sources.rs
··· 14 14 pub use slices::SlicesSource; 15 15 16 16 use crate::lexicon::LexiconDoc; 17 - use async_trait::async_trait; 18 17 use miette::{IntoDiagnostic, Result}; 19 18 use std::collections::HashMap; 19 + use std::future::Future; 20 20 21 21 #[derive(Debug, Clone)] 22 22 pub struct Source { ··· 58 58 Slices(SlicesSource), 59 59 } 60 60 61 - #[async_trait] 62 61 pub trait LexiconSource { 63 - fn fetch(&self) -> impl Future<Output = Result<HashMap<String, LexiconDoc<'_>>>>; 62 + fn fetch(&self) -> impl Future<Output = Result<HashMap<String, LexiconDoc<'_>>>> + Send; 64 63 } 65 64 66 65 impl LexiconSource for SourceType {
+5 -3
crates/jacquard-oauth/Cargo.toml
··· 33 33 http.workspace = true 34 34 bytes.workspace = true 35 35 rand = { version = "0.8.5", features = ["small_rng"] } 36 - async-trait.workspace = true 37 36 dashmap = "6.1.0" 38 - tokio = { workspace = true, features = ["sync", "net", "time"] } 37 + tokio = { workspace = true, default-features = false, features = ["sync"] } 39 38 reqwest.workspace = true 40 39 trait-variant.workspace = true 41 40 webbrowser = { version = "0.8", optional = true } 42 - rouille = { version = "3.6.2", optional = true } 43 41 tracing = { workspace = true, optional = true } 42 + 43 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 44 + tokio = { workspace = true, features = ["rt", "net", "time"] } 45 + rouille = { version = "3.6.2", optional = true } 44 46 45 47 [features] 46 48 default = []
+13 -14
crates/jacquard-oauth/src/authstore.rs
··· 1 + use std::future::Future; 1 2 use std::sync::Arc; 2 3 3 4 use dashmap::DashMap; ··· 10 11 11 12 use crate::session::{AuthRequestData, ClientSessionData}; 12 13 13 - #[async_trait::async_trait] 14 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 14 15 pub trait ClientAuthStore { 15 - async fn get_session( 16 + fn get_session( 16 17 &self, 17 18 did: &Did<'_>, 18 19 session_id: &str, 19 - ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>; 20 + ) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>>; 20 21 21 - async fn upsert_session(&self, session: ClientSessionData<'_>) 22 - -> Result<(), SessionStoreError>; 22 + fn upsert_session(&self, session: ClientSessionData<'_>) 23 + -> impl Future<Output = Result<(), SessionStoreError>>; 23 24 24 - async fn delete_session( 25 + fn delete_session( 25 26 &self, 26 27 did: &Did<'_>, 27 28 session_id: &str, 28 - ) -> Result<(), SessionStoreError>; 29 + ) -> impl Future<Output = Result<(), SessionStoreError>>; 29 30 30 - async fn get_auth_req_info( 31 + fn get_auth_req_info( 31 32 &self, 32 33 state: &str, 33 - ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>; 34 + ) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>>; 34 35 35 - async fn save_auth_req_info( 36 + fn save_auth_req_info( 36 37 &self, 37 38 auth_req_info: &AuthRequestData<'_>, 38 - ) -> Result<(), SessionStoreError>; 39 + ) -> impl Future<Output = Result<(), SessionStoreError>>; 39 40 40 - async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>; 41 + fn delete_auth_req_info(&self, state: &str) -> impl Future<Output = Result<(), SessionStoreError>>; 41 42 } 42 43 43 44 pub struct MemoryAuthStore { ··· 54 55 } 55 56 } 56 57 57 - #[async_trait::async_trait] 58 58 impl ClientAuthStore for MemoryAuthStore { 59 59 async fn get_session( 60 60 &self, ··· 108 108 } 109 109 } 110 110 111 - #[async_trait::async_trait] 112 111 impl<T: ClientAuthStore + Send + Sync> 113 112 SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T> 114 113 {
+6 -8
crates/jacquard-oauth/src/client.rs
··· 421 421 { 422 422 fn base_uri(&self) -> Url { 423 423 // base_uri is a synchronous trait method; we must avoid async `.read().await`. 424 - // Use `block_in_place` under Tokio to perform a blocking RwLock read safely. 424 + // Use `block_in_place` under Tokio runtime to perform a blocking RwLock read safely. 425 + #[cfg(not(target_arch = "wasm32"))] 425 426 if tokio::runtime::Handle::try_current().is_ok() { 426 - tokio::task::block_in_place(|| self.data.blocking_read().host_url.clone()) 427 - } else { 428 - self.data.blocking_read().host_url.clone() 427 + return tokio::task::block_in_place(|| self.data.blocking_read().host_url.clone()); 429 428 } 429 + 430 + self.data.blocking_read().host_url.clone() 430 431 } 431 432 432 433 async fn opts(&self) -> CallOptions<'_> { 433 434 self.options.read().await.clone() 434 435 } 435 436 436 - async fn send<R>( 437 - &self, 438 - request: R, 439 - ) -> XrpcResult<Response<<R as XrpcRequest>::Response>> 437 + async fn send<R>(&self, request: R) -> XrpcResult<Response<<R as XrpcRequest>::Response>> 440 438 where 441 439 R: XrpcRequest, 442 440 {
+4 -4
crates/jacquard-oauth/src/dpop.rs
··· 41 41 42 42 type Result<T> = core::result::Result<T, Error>; 43 43 44 - #[async_trait::async_trait] 44 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 45 45 pub trait DpopClient: HttpClient { 46 - async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 47 - async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 48 - async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 46 + fn dpop_server(&self, request: Request<Vec<u8>>) -> impl std::future::Future<Output = Result<Response<Vec<u8>>>>; 47 + fn dpop_client(&self, request: Request<Vec<u8>>) -> impl std::future::Future<Output = Result<Response<Vec<u8>>>>; 48 + fn wrap_request(&self, request: Request<Vec<u8>>) -> impl std::future::Future<Output = Result<Response<Vec<u8>>>>; 49 49 } 50 50 51 51 pub trait DpopExt: HttpClient {
+377 -100
crates/jacquard-oauth/src/resolver.rs
··· 115 115 Uri(#[from] url::ParseError), 116 116 } 117 117 118 + #[cfg(not(target_arch = "wasm32"))] 119 + async fn verify_issuer_impl<T: OAuthResolver + Sync + ?Sized>( 120 + resolver: &T, 121 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 122 + sub: &Did<'_>, 123 + ) -> Result<Url, ResolverError> { 124 + let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 125 + if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 126 + return Err(ResolverError::AuthorizationServerMetadata( 127 + "issuer mismatch".to_string(), 128 + )); 129 + } 130 + Ok(identity 131 + .pds_endpoint() 132 + .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 133 + } 134 + 135 + #[cfg(target_arch = "wasm32")] 136 + async fn verify_issuer_impl<T: OAuthResolver + ?Sized>( 137 + resolver: &T, 138 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 139 + sub: &Did<'_>, 140 + ) -> Result<Url, ResolverError> { 141 + let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 142 + if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 143 + return Err(ResolverError::AuthorizationServerMetadata( 144 + "issuer mismatch".to_string(), 145 + )); 146 + } 147 + Ok(identity 148 + .pds_endpoint() 149 + .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 150 + } 151 + 152 + #[cfg(not(target_arch = "wasm32"))] 153 + async fn resolve_oauth_impl<T: OAuthResolver + Sync + ?Sized>( 154 + resolver: &T, 155 + input: &str, 156 + ) -> Result< 157 + ( 158 + OAuthAuthorizationServerMetadata<'static>, 159 + Option<DidDocument<'static>>, 160 + ), 161 + ResolverError, 162 + > { 163 + // Allow using an entryway, or PDS url, directly as login input (e.g. 164 + // when the user forgot their handle, or when the handle does not 165 + // resolve to a DID) 166 + Ok(if input.starts_with("https://") { 167 + let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 168 + (resolver.resolve_from_service(&url).await?, None) 169 + } else { 170 + let (metadata, identity) = resolver.resolve_from_identity(input).await?; 171 + (metadata, Some(identity)) 172 + }) 173 + } 174 + 175 + #[cfg(target_arch = "wasm32")] 176 + async fn resolve_oauth_impl<T: OAuthResolver + ?Sized>( 177 + resolver: &T, 178 + input: &str, 179 + ) -> Result< 180 + ( 181 + OAuthAuthorizationServerMetadata<'static>, 182 + Option<DidDocument<'static>>, 183 + ), 184 + ResolverError, 185 + > { 186 + // Allow using an entryway, or PDS url, directly as login input (e.g. 187 + // when the user forgot their handle, or when the handle does not 188 + // resolve to a DID) 189 + Ok(if input.starts_with("https://") { 190 + let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 191 + (resolver.resolve_from_service(&url).await?, None) 192 + } else { 193 + let (metadata, identity) = resolver.resolve_from_identity(input).await?; 194 + (metadata, Some(identity)) 195 + }) 196 + } 197 + 198 + #[cfg(not(target_arch = "wasm32"))] 199 + async fn resolve_from_service_impl<T: OAuthResolver + Sync + ?Sized>( 200 + resolver: &T, 201 + input: &Url, 202 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 203 + // Assume first that input is a PDS URL (as required by ATPROTO) 204 + if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 205 + return Ok(metadata); 206 + } 207 + // Fallback to trying to fetch as an issuer (Entryway) 208 + resolver.get_authorization_server_metadata(input).await 209 + } 210 + 211 + #[cfg(target_arch = "wasm32")] 212 + async fn resolve_from_service_impl<T: OAuthResolver + ?Sized>( 213 + resolver: &T, 214 + input: &Url, 215 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 216 + // Assume first that input is a PDS URL (as required by ATPROTO) 217 + if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 218 + return Ok(metadata); 219 + } 220 + // Fallback to trying to fetch as an issuer (Entryway) 221 + resolver.get_authorization_server_metadata(input).await 222 + } 223 + 224 + #[cfg(not(target_arch = "wasm32"))] 225 + async fn resolve_from_identity_impl<T: OAuthResolver + Sync + ?Sized>( 226 + resolver: &T, 227 + input: &str, 228 + ) -> Result< 229 + ( 230 + OAuthAuthorizationServerMetadata<'static>, 231 + DidDocument<'static>, 232 + ), 233 + ResolverError, 234 + > { 235 + let actor = 236 + AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 237 + let identity = resolver.resolve_ident_owned(&actor).await?; 238 + if let Some(pds) = &identity.pds_endpoint() { 239 + let metadata = resolver.get_resource_server_metadata(pds).await?; 240 + Ok((metadata, identity)) 241 + } else { 242 + Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 243 + } 244 + } 245 + 246 + #[cfg(target_arch = "wasm32")] 247 + async fn resolve_from_identity_impl<T: OAuthResolver + ?Sized>( 248 + resolver: &T, 249 + input: &str, 250 + ) -> Result< 251 + ( 252 + OAuthAuthorizationServerMetadata<'static>, 253 + DidDocument<'static>, 254 + ), 255 + ResolverError, 256 + > { 257 + let actor = 258 + AtIdentifier::new(input).map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 259 + let identity = resolver.resolve_ident_owned(&actor).await?; 260 + if let Some(pds) = &identity.pds_endpoint() { 261 + let metadata = resolver.get_resource_server_metadata(pds).await?; 262 + Ok((metadata, identity)) 263 + } else { 264 + Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 265 + } 266 + } 267 + 268 + #[cfg(not(target_arch = "wasm32"))] 269 + async fn get_authorization_server_metadata_impl<T: HttpClient + Sync + ?Sized>( 270 + client: &T, 271 + issuer: &Url, 272 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 273 + let mut md = resolve_authorization_server(client, issuer).await?; 274 + // Normalize issuer string to the input URL representation to avoid slash quirks 275 + md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); 276 + Ok(md) 277 + } 278 + 279 + #[cfg(target_arch = "wasm32")] 280 + async fn get_authorization_server_metadata_impl<T: HttpClient + ?Sized>( 281 + client: &T, 282 + issuer: &Url, 283 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 284 + let mut md = resolve_authorization_server(client, issuer).await?; 285 + // Normalize issuer string to the input URL representation to avoid slash quirks 286 + md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); 287 + Ok(md) 288 + } 289 + 290 + #[cfg(not(target_arch = "wasm32"))] 291 + async fn get_resource_server_metadata_impl<T: OAuthResolver + Sync + ?Sized>( 292 + resolver: &T, 293 + pds: &Url, 294 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 295 + let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 296 + // ATPROTO requires one, and only one, authorization server entry 297 + // > That document MUST contain a single item in the authorization_servers array. 298 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 299 + let issuer = match &rs_metadata.authorization_servers { 300 + Some(servers) if !servers.is_empty() => { 301 + if servers.len() > 1 { 302 + return Err(ResolverError::ProtectedResourceMetadata(format!( 303 + "unable to determine authorization server for PDS: {pds}" 304 + ))); 305 + } 306 + &servers[0] 307 + } 308 + _ => { 309 + return Err(ResolverError::ProtectedResourceMetadata(format!( 310 + "no authorization server found for PDS: {pds}" 311 + ))); 312 + } 313 + }; 314 + let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; 315 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 316 + if let Some(protected_resources) = &as_metadata.protected_resources { 317 + let resource_url = rs_metadata 318 + .resource 319 + .strip_suffix('/') 320 + .unwrap_or(rs_metadata.resource.as_str()); 321 + if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 322 + return Err(ResolverError::AuthorizationServerMetadata(format!( 323 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 324 + rs_metadata.resource, protected_resources 325 + ))); 326 + } 327 + } 328 + 329 + // TODO: atproot specific validation? 330 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 331 + // 332 + // eg. 333 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 334 + // if as_metadata.client_id_metadata_document_supported != Some(true) { 335 + // return Err(Error::AuthorizationServerMetadata(format!( 336 + // "authorization server does not support client_id_metadata_document: {issuer}" 337 + // ))); 338 + // } 339 + 340 + Ok(as_metadata) 341 + } 342 + 343 + #[cfg(target_arch = "wasm32")] 344 + async fn get_resource_server_metadata_impl<T: OAuthResolver + ?Sized>( 345 + resolver: &T, 346 + pds: &Url, 347 + ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 348 + let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 349 + // ATPROTO requires one, and only one, authorization server entry 350 + // > That document MUST contain a single item in the authorization_servers array. 351 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 352 + let issuer = match &rs_metadata.authorization_servers { 353 + Some(servers) if !servers.is_empty() => { 354 + if servers.len() > 1 { 355 + return Err(ResolverError::ProtectedResourceMetadata(format!( 356 + "unable to determine authorization server for PDS: {pds}" 357 + ))); 358 + } 359 + &servers[0] 360 + } 361 + _ => { 362 + return Err(ResolverError::ProtectedResourceMetadata(format!( 363 + "no authorization server found for PDS: {pds}" 364 + ))); 365 + } 366 + }; 367 + let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; 368 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 369 + if let Some(protected_resources) = &as_metadata.protected_resources { 370 + let resource_url = rs_metadata 371 + .resource 372 + .strip_suffix('/') 373 + .unwrap_or(rs_metadata.resource.as_str()); 374 + if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 375 + return Err(ResolverError::AuthorizationServerMetadata(format!( 376 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 377 + rs_metadata.resource, protected_resources 378 + ))); 379 + } 380 + } 381 + 382 + // TODO: atproot specific validation? 383 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 384 + // 385 + // eg. 386 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 387 + // if as_metadata.client_id_metadata_document_supported != Some(true) { 388 + // return Err(Error::AuthorizationServerMetadata(format!( 389 + // "authorization server does not support client_id_metadata_document: {issuer}" 390 + // ))); 391 + // } 392 + 393 + Ok(as_metadata) 394 + } 395 + 396 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 118 397 pub trait OAuthResolver: IdentityResolver + HttpClient { 398 + #[cfg(not(target_arch = "wasm32"))] 119 399 fn verify_issuer( 120 400 &self, 121 401 server_metadata: &OAuthAuthorizationServerMetadata<'_>, ··· 124 404 where 125 405 Self: Sync, 126 406 { 127 - async { 128 - let (metadata, identity) = self.resolve_from_identity(sub).await?; 129 - if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 130 - return Err(ResolverError::AuthorizationServerMetadata( 131 - "issuer mismatch".to_string(), 132 - )); 133 - } 134 - Ok(identity 135 - .pds_endpoint() 136 - .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 137 - } 407 + verify_issuer_impl(self, server_metadata, sub) 408 + } 409 + 410 + #[cfg(target_arch = "wasm32")] 411 + fn verify_issuer( 412 + &self, 413 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 414 + sub: &Did<'_>, 415 + ) -> impl std::future::Future<Output = Result<Url, ResolverError>> { 416 + verify_issuer_impl(self, server_metadata, sub) 138 417 } 418 + 419 + #[cfg(not(target_arch = "wasm32"))] 139 420 fn resolve_oauth( 140 421 &self, 141 422 input: &str, 142 - ) -> impl Future< 423 + ) -> impl std::future::Future< 143 424 Output = Result< 144 425 ( 145 426 OAuthAuthorizationServerMetadata<'static>, ··· 151 432 where 152 433 Self: Sync, 153 434 { 154 - // Allow using an entryway, or PDS url, directly as login input (e.g. 155 - // when the user forgot their handle, or when the handle does not 156 - // resolve to a DID) 157 - async { 158 - Ok(if input.starts_with("https://") { 159 - let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 160 - (self.resolve_from_service(&url).await?, None) 161 - } else { 162 - let (metadata, identity) = self.resolve_from_identity(input).await?; 163 - (metadata, Some(identity)) 164 - }) 165 - } 435 + resolve_oauth_impl(self, input) 436 + } 437 + 438 + #[cfg(target_arch = "wasm32")] 439 + fn resolve_oauth( 440 + &self, 441 + input: &str, 442 + ) -> impl std::future::Future< 443 + Output = Result< 444 + ( 445 + OAuthAuthorizationServerMetadata<'static>, 446 + Option<DidDocument<'static>>, 447 + ), 448 + ResolverError, 449 + >, 450 + > { 451 + resolve_oauth_impl(self, input) 166 452 } 453 + 454 + #[cfg(not(target_arch = "wasm32"))] 167 455 fn resolve_from_service( 168 456 &self, 169 457 input: &Url, 170 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send 458 + ) -> impl std::future::Future< 459 + Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>, 460 + > + Send 171 461 where 172 462 Self: Sync, 173 463 { 174 - async { 175 - // Assume first that input is a PDS URL (as required by ATPROTO) 176 - if let Ok(metadata) = self.get_resource_server_metadata(input).await { 177 - return Ok(metadata); 178 - } 179 - // Fallback to trying to fetch as an issuer (Entryway) 180 - self.get_authorization_server_metadata(input).await 181 - } 464 + resolve_from_service_impl(self, input) 182 465 } 466 + 467 + #[cfg(target_arch = "wasm32")] 468 + fn resolve_from_service( 469 + &self, 470 + input: &Url, 471 + ) -> impl std::future::Future< 472 + Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>, 473 + > { 474 + resolve_from_service_impl(self, input) 475 + } 476 + 477 + #[cfg(not(target_arch = "wasm32"))] 183 478 fn resolve_from_identity( 184 479 &self, 185 480 input: &str, 186 - ) -> impl Future< 481 + ) -> impl std::future::Future< 187 482 Output = Result< 188 483 ( 189 484 OAuthAuthorizationServerMetadata<'static>, ··· 195 490 where 196 491 Self: Sync, 197 492 { 198 - async { 199 - let actor = AtIdentifier::new(input) 200 - .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 201 - let identity = self.resolve_ident_owned(&actor).await?; 202 - if let Some(pds) = &identity.pds_endpoint() { 203 - let metadata = self.get_resource_server_metadata(pds).await?; 204 - Ok((metadata, identity)) 205 - } else { 206 - Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 207 - } 208 - } 493 + resolve_from_identity_impl(self, input) 209 494 } 495 + 496 + #[cfg(target_arch = "wasm32")] 497 + fn resolve_from_identity( 498 + &self, 499 + input: &str, 500 + ) -> impl std::future::Future< 501 + Output = Result< 502 + ( 503 + OAuthAuthorizationServerMetadata<'static>, 504 + DidDocument<'static>, 505 + ), 506 + ResolverError, 507 + >, 508 + > { 509 + resolve_from_identity_impl(self, input) 510 + } 511 + 512 + #[cfg(not(target_arch = "wasm32"))] 210 513 fn get_authorization_server_metadata( 211 514 &self, 212 515 issuer: &Url, 213 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send 516 + ) -> impl std::future::Future< 517 + Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>, 518 + > + Send 214 519 where 215 520 Self: Sync, 216 521 { 217 - async { 218 - let mut md = resolve_authorization_server(self, issuer).await?; 219 - // Normalize issuer string to the input URL representation to avoid slash quirks 220 - md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); 221 - Ok(md) 222 - } 522 + get_authorization_server_metadata_impl(self, issuer) 223 523 } 524 + 525 + #[cfg(target_arch = "wasm32")] 526 + fn get_authorization_server_metadata( 527 + &self, 528 + issuer: &Url, 529 + ) -> impl std::future::Future< 530 + Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>, 531 + > { 532 + get_authorization_server_metadata_impl(self, issuer) 533 + } 534 + 535 + #[cfg(not(target_arch = "wasm32"))] 224 536 fn get_resource_server_metadata( 225 537 &self, 226 538 pds: &Url, 227 - ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send 539 + ) -> impl std::future::Future< 540 + Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>, 541 + > + Send 228 542 where 229 543 Self: Sync, 230 544 { 231 - async move { 232 - let rs_metadata = resolve_protected_resource_info(self, pds).await?; 233 - // ATPROTO requires one, and only one, authorization server entry 234 - // > That document MUST contain a single item in the authorization_servers array. 235 - // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 236 - let issuer = match &rs_metadata.authorization_servers { 237 - Some(servers) if !servers.is_empty() => { 238 - if servers.len() > 1 { 239 - return Err(ResolverError::ProtectedResourceMetadata(format!( 240 - "unable to determine authorization server for PDS: {pds}" 241 - ))); 242 - } 243 - &servers[0] 244 - } 245 - _ => { 246 - return Err(ResolverError::ProtectedResourceMetadata(format!( 247 - "no authorization server found for PDS: {pds}" 248 - ))); 249 - } 250 - }; 251 - let as_metadata = self.get_authorization_server_metadata(issuer).await?; 252 - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 253 - if let Some(protected_resources) = &as_metadata.protected_resources { 254 - let resource_url = rs_metadata 255 - .resource 256 - .strip_suffix('/') 257 - .unwrap_or(rs_metadata.resource.as_str()); 258 - if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 259 - return Err(ResolverError::AuthorizationServerMetadata(format!( 260 - "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 261 - rs_metadata.resource, protected_resources 262 - ))); 263 - } 264 - } 545 + get_resource_server_metadata_impl(self, pds) 546 + } 265 547 266 - // TODO: atproot specific validation? 267 - // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 268 - // 269 - // eg. 270 - // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 271 - // if as_metadata.client_id_metadata_document_supported != Some(true) { 272 - // return Err(Error::AuthorizationServerMetadata(format!( 273 - // "authorization server does not support client_id_metadata_document: {issuer}" 274 - // ))); 275 - // } 276 - 277 - Ok(as_metadata) 278 - } 548 + #[cfg(target_arch = "wasm32")] 549 + fn get_resource_server_metadata( 550 + &self, 551 + pds: &Url, 552 + ) -> impl std::future::Future< 553 + Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>, 554 + > { 555 + get_resource_server_metadata_impl(self, pds) 279 556 } 280 557 } 281 558
+12 -4
crates/jacquard/Cargo.toml
··· 22 22 api_full = ["api", "jacquard-api/bluesky", "jacquard-api/other", "jacquard-api/lexicon_community"] 23 23 # All captured generated lexicon API bindings 24 24 api_all = ["api_full", "jacquard-api/ufos"] 25 - dns = ["jacquard-identity/dns"] 25 + 26 26 # Propagate loopback to oauth (server + browser helper) 27 27 loopback = ["jacquard-oauth/loopback", "jacquard-oauth/browser-open"] 28 28 # Enable tracing instrumentation 29 29 tracing = ["dep:tracing", "jacquard-common/tracing", "jacquard-oauth/tracing", "jacquard-identity/tracing"] 30 + dns = ["jacquard-identity/dns"] 30 31 31 32 32 33 [[example]] ··· 78 79 jacquard-identity = { version = "0.5", path = "../jacquard-identity" } 79 80 80 81 bon.workspace = true 81 - async-trait.workspace = true 82 + trait-variant.workspace = true 82 83 bytes.workspace = true 83 84 http.workspace = true 84 85 miette = { workspace = true } 85 - reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 86 + reqwest = { workspace = true, features = ["charset", "json", "gzip"] } 86 87 serde.workspace = true 87 88 serde_html_form.workspace = true 88 89 serde_ipld_dagcbor.workspace = true 89 90 serde_json.workspace = true 90 91 thiserror.workspace = true 91 - tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } 92 + tokio = { workspace = true, default-features = false, features = ["sync"] } 92 93 url.workspace = true 93 94 smol_str.workspace = true 94 95 percent-encoding.workspace = true ··· 96 97 p256 = { workspace = true, features = ["ecdsa"] } 97 98 rand_core.workspace = true 98 99 tracing = { workspace = true, optional = true } 100 + 101 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 102 + reqwest = { workspace = true, features = ["http2", "system-proxy", "rustls-tls"] } 103 + tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } 104 + 105 + [target.'cfg(target_family = "wasm")'.dependencies] 106 + getrandom = { version = "0.2", features = ["js"] } 99 107 100 108 [dev-dependencies] 101 109 clap.workspace = true
+10
crates/jacquard/src/client.rs
··· 190 190 /// Common interface for stateful sessions used by the Agent wrapper. 191 191 /// 192 192 /// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP). 193 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 193 194 pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { 194 195 /// Identify the kind of session. 195 196 fn session_kind(&self) -> AgentKind; ··· 877 878 impl<A: AgentSession> HttpClient for Agent<A> { 878 879 type Error = <A as HttpClient>::Error; 879 880 881 + #[cfg(not(target_arch = "wasm32"))] 880 882 fn send_http( 881 883 &self, 882 884 request: http::Request<Vec<u8>>, 883 885 ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 884 886 { 887 + self.inner.send_http(request) 888 + } 889 + 890 + #[cfg(target_arch = "wasm32")] 891 + fn send_http( 892 + &self, 893 + request: http::Request<Vec<u8>>, 894 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> { 885 895 self.inner.send_http(request) 886 896 } 887 897 }
+13 -2
crates/jacquard/src/client/credential_session.rs
··· 168 168 S: Any + 'static, 169 169 { 170 170 #[cfg(feature = "tracing")] 171 - let _span = tracing::info_span!("credential_session_login", identifier = %identifier).entered(); 171 + let _span = 172 + tracing::info_span!("credential_session_login", identifier = %identifier).entered(); 172 173 173 174 // Resolve PDS base 174 175 let pds = if identifier.as_ref().starts_with("http://") ··· 272 273 S: Any + 'static, 273 274 { 274 275 #[cfg(feature = "tracing")] 275 - let _span = tracing::info_span!("credential_session_restore", did = %did, session_id = %session_id).entered(); 276 + let _span = 277 + tracing::info_span!("credential_session_restore", did = %did, session_id = %session_id) 278 + .entered(); 276 279 277 280 let key = (did.clone().into_static(), session_id.clone().into_static()); 278 281 let Some(sess) = self.store.get(&key).await else { ··· 401 404 fn base_uri(&self) -> Url { 402 405 // base_uri is a synchronous trait method; avoid `.await` here. 403 406 // Under Tokio, use `block_in_place` to make a blocking RwLock read safe. 407 + #[cfg(not(target_arch = "wasm32"))] 404 408 if tokio::runtime::Handle::try_current().is_ok() { 405 409 tokio::task::block_in_place(|| { 406 410 self.endpoint.blocking_read().clone().unwrap_or( ··· 409 413 ) 410 414 }) 411 415 } else { 416 + self.endpoint.blocking_read().clone().unwrap_or( 417 + Url::parse("https://public.bsky.app").expect("public appview should be valid url"), 418 + ) 419 + } 420 + 421 + #[cfg(target_arch = "wasm32")] 422 + { 412 423 self.endpoint.blocking_read().clone().unwrap_or( 413 424 Url::parse("https://public.bsky.app").expect("public appview should be valid url"), 414 425 )
-2
crates/jacquard/src/client/token.rs
··· 246 246 } 247 247 } 248 248 249 - #[async_trait::async_trait] 250 249 impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore { 251 250 async fn get_session( 252 251 &self, ··· 388 387 } 389 388 } 390 389 391 - #[async_trait::async_trait] 392 390 impl 393 391 jacquard_common::session::SessionStore< 394 392 crate::client::credential_session::SessionKey,
+4
justfile
··· 5 5 pre-commit-all: 6 6 pre-commit run --all-files 7 7 8 + # Check that jacquard-common compiles for wasm32 9 + check-wasm: 10 + cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 11 + 8 12 # Run 'cargo run' on the project 9 13 run *ARGS: 10 14 cargo run {{ARGS}}
+1
rust-toolchain.toml
··· 1 1 [toolchain] 2 2 channel = "stable" 3 3 profile = "default" 4 + targets = [ "wasm32-unknown-unknown" ]