A better Rust ATProto crate

oauth and loopback callback handler work properly, able to make authed xrpc requests

Orual 2ff4604b 2112129b

Changed files
+658 -111
crates
+190 -1
Cargo.lock
··· 381 381 ] 382 382 383 383 [[package]] 384 + name = "cesu8" 385 + version = "1.1.0" 386 + source = "registry+https://github.com/rust-lang/crates.io-index" 387 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 388 + 389 + [[package]] 384 390 name = "cfg-if" 385 391 version = "1.0.3" 386 392 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 498 504 version = "1.0.4" 499 505 source = "registry+https://github.com/rust-lang/crates.io-index" 500 506 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 507 + 508 + [[package]] 509 + name = "combine" 510 + version = "4.6.7" 511 + source = "registry+https://github.com/rust-lang/crates.io-index" 512 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 513 + dependencies = [ 514 + "bytes", 515 + "memchr", 516 + ] 501 517 502 518 [[package]] 503 519 name = "compression-codecs" ··· 1166 1182 ] 1167 1183 1168 1184 [[package]] 1185 + name = "home" 1186 + version = "0.5.11" 1187 + source = "registry+https://github.com/rust-lang/crates.io-index" 1188 + checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" 1189 + dependencies = [ 1190 + "windows-sys 0.59.0", 1191 + ] 1192 + 1193 + [[package]] 1169 1194 name = "http" 1170 1195 version = "1.3.1" 1171 1196 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1539 1564 "percent-encoding", 1540 1565 "rand_core 0.6.4", 1541 1566 "reqwest", 1542 - "rouille", 1543 1567 "serde", 1544 1568 "serde_html_form", 1545 1569 "serde_ipld_dagcbor", ··· 1681 1705 "rand 0.8.5", 1682 1706 "rand_core 0.6.4", 1683 1707 "reqwest", 1708 + "rouille", 1684 1709 "serde", 1685 1710 "serde_html_form", 1686 1711 "serde_json", ··· 1692 1717 "trait-variant", 1693 1718 "url", 1694 1719 "uuid", 1720 + "webbrowser", 1695 1721 ] 1696 1722 1697 1723 [[package]] 1724 + name = "jni" 1725 + version = "0.21.1" 1726 + source = "registry+https://github.com/rust-lang/crates.io-index" 1727 + checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 1728 + dependencies = [ 1729 + "cesu8", 1730 + "cfg-if", 1731 + "combine", 1732 + "jni-sys", 1733 + "log", 1734 + "thiserror 1.0.69", 1735 + "walkdir", 1736 + "windows-sys 0.45.0", 1737 + ] 1738 + 1739 + [[package]] 1740 + name = "jni-sys" 1741 + version = "0.3.0" 1742 + source = "registry+https://github.com/rust-lang/crates.io-index" 1743 + checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 1744 + 1745 + [[package]] 1698 1746 name = "jose-b64" 1699 1747 version = "0.1.2" 1700 1748 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1842 1890 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1843 1891 1844 1892 [[package]] 1893 + name = "malloc_buf" 1894 + version = "0.0.6" 1895 + source = "registry+https://github.com/rust-lang/crates.io-index" 1896 + checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" 1897 + dependencies = [ 1898 + "libc", 1899 + ] 1900 + 1901 + [[package]] 1845 1902 name = "memchr" 1846 1903 version = "2.7.6" 1847 1904 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1960 2017 ] 1961 2018 1962 2019 [[package]] 2020 + name = "ndk-context" 2021 + version = "0.1.1" 2022 + source = "registry+https://github.com/rust-lang/crates.io-index" 2023 + checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 2024 + 2025 + [[package]] 1963 2026 name = "nom" 1964 2027 version = "7.1.3" 1965 2028 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2042 2105 ] 2043 2106 2044 2107 [[package]] 2108 + name = "objc" 2109 + version = "0.2.7" 2110 + source = "registry+https://github.com/rust-lang/crates.io-index" 2111 + checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" 2112 + dependencies = [ 2113 + "malloc_buf", 2114 + ] 2115 + 2116 + [[package]] 2045 2117 name = "object" 2046 2118 version = "0.37.3" 2047 2119 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2416 2488 checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" 2417 2489 2418 2490 [[package]] 2491 + name = "raw-window-handle" 2492 + version = "0.5.2" 2493 + source = "registry+https://github.com/rust-lang/crates.io-index" 2494 + checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" 2495 + 2496 + [[package]] 2419 2497 name = "redox_syscall" 2420 2498 version = "0.5.18" 2421 2499 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2677 2755 version = "0.3.3" 2678 2756 source = "registry+https://github.com/rust-lang/crates.io-index" 2679 2757 checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 2758 + 2759 + [[package]] 2760 + name = "same-file" 2761 + version = "1.0.6" 2762 + source = "registry+https://github.com/rust-lang/crates.io-index" 2763 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 2764 + dependencies = [ 2765 + "winapi-util", 2766 + ] 2680 2767 2681 2768 [[package]] 2682 2769 name = "schemars" ··· 3480 3567 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3481 3568 3482 3569 [[package]] 3570 + name = "walkdir" 3571 + version = "2.5.0" 3572 + source = "registry+https://github.com/rust-lang/crates.io-index" 3573 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 3574 + dependencies = [ 3575 + "same-file", 3576 + "winapi-util", 3577 + ] 3578 + 3579 + [[package]] 3483 3580 name = "want" 3484 3581 version = "0.3.1" 3485 3582 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3605 3702 ] 3606 3703 3607 3704 [[package]] 3705 + name = "webbrowser" 3706 + version = "0.8.15" 3707 + source = "registry+https://github.com/rust-lang/crates.io-index" 3708 + checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" 3709 + dependencies = [ 3710 + "core-foundation", 3711 + "home", 3712 + "jni", 3713 + "log", 3714 + "ndk-context", 3715 + "objc", 3716 + "raw-window-handle", 3717 + "url", 3718 + "web-sys", 3719 + ] 3720 + 3721 + [[package]] 3608 3722 name = "webpki-roots" 3609 3723 version = "1.0.2" 3610 3724 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3620 3734 checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" 3621 3735 3622 3736 [[package]] 3737 + name = "winapi-util" 3738 + version = "0.1.11" 3739 + source = "registry+https://github.com/rust-lang/crates.io-index" 3740 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 3741 + dependencies = [ 3742 + "windows-sys 0.60.2", 3743 + ] 3744 + 3745 + [[package]] 3623 3746 name = "windows-core" 3624 3747 version = "0.62.1" 3625 3748 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3715 3838 3716 3839 [[package]] 3717 3840 name = "windows-sys" 3841 + version = "0.45.0" 3842 + source = "registry+https://github.com/rust-lang/crates.io-index" 3843 + checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 3844 + dependencies = [ 3845 + "windows-targets 0.42.2", 3846 + ] 3847 + 3848 + [[package]] 3849 + name = "windows-sys" 3718 3850 version = "0.48.0" 3719 3851 source = "registry+https://github.com/rust-lang/crates.io-index" 3720 3852 checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" ··· 3751 3883 3752 3884 [[package]] 3753 3885 name = "windows-targets" 3886 + version = "0.42.2" 3887 + source = "registry+https://github.com/rust-lang/crates.io-index" 3888 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 3889 + dependencies = [ 3890 + "windows_aarch64_gnullvm 0.42.2", 3891 + "windows_aarch64_msvc 0.42.2", 3892 + "windows_i686_gnu 0.42.2", 3893 + "windows_i686_msvc 0.42.2", 3894 + "windows_x86_64_gnu 0.42.2", 3895 + "windows_x86_64_gnullvm 0.42.2", 3896 + "windows_x86_64_msvc 0.42.2", 3897 + ] 3898 + 3899 + [[package]] 3900 + name = "windows-targets" 3754 3901 version = "0.48.5" 3755 3902 source = "registry+https://github.com/rust-lang/crates.io-index" 3756 3903 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" ··· 3799 3946 3800 3947 [[package]] 3801 3948 name = "windows_aarch64_gnullvm" 3949 + version = "0.42.2" 3950 + source = "registry+https://github.com/rust-lang/crates.io-index" 3951 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 3952 + 3953 + [[package]] 3954 + name = "windows_aarch64_gnullvm" 3802 3955 version = "0.48.5" 3803 3956 source = "registry+https://github.com/rust-lang/crates.io-index" 3804 3957 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" ··· 3817 3970 3818 3971 [[package]] 3819 3972 name = "windows_aarch64_msvc" 3973 + version = "0.42.2" 3974 + source = "registry+https://github.com/rust-lang/crates.io-index" 3975 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 3976 + 3977 + [[package]] 3978 + name = "windows_aarch64_msvc" 3820 3979 version = "0.48.5" 3821 3980 source = "registry+https://github.com/rust-lang/crates.io-index" 3822 3981 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" ··· 3832 3991 version = "0.53.0" 3833 3992 source = "registry+https://github.com/rust-lang/crates.io-index" 3834 3993 checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 3994 + 3995 + [[package]] 3996 + name = "windows_i686_gnu" 3997 + version = "0.42.2" 3998 + source = "registry+https://github.com/rust-lang/crates.io-index" 3999 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 3835 4000 3836 4001 [[package]] 3837 4002 name = "windows_i686_gnu" ··· 3865 4030 3866 4031 [[package]] 3867 4032 name = "windows_i686_msvc" 4033 + version = "0.42.2" 4034 + source = "registry+https://github.com/rust-lang/crates.io-index" 4035 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 4036 + 4037 + [[package]] 4038 + name = "windows_i686_msvc" 3868 4039 version = "0.48.5" 3869 4040 source = "registry+https://github.com/rust-lang/crates.io-index" 3870 4041 checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" ··· 3883 4054 3884 4055 [[package]] 3885 4056 name = "windows_x86_64_gnu" 4057 + version = "0.42.2" 4058 + source = "registry+https://github.com/rust-lang/crates.io-index" 4059 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 4060 + 4061 + [[package]] 4062 + name = "windows_x86_64_gnu" 3886 4063 version = "0.48.5" 3887 4064 source = "registry+https://github.com/rust-lang/crates.io-index" 3888 4065 checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" ··· 3901 4078 3902 4079 [[package]] 3903 4080 name = "windows_x86_64_gnullvm" 4081 + version = "0.42.2" 4082 + source = "registry+https://github.com/rust-lang/crates.io-index" 4083 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 4084 + 4085 + [[package]] 4086 + name = "windows_x86_64_gnullvm" 3904 4087 version = "0.48.5" 3905 4088 source = "registry+https://github.com/rust-lang/crates.io-index" 3906 4089 checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" ··· 3916 4099 version = "0.53.0" 3917 4100 source = "registry+https://github.com/rust-lang/crates.io-index" 3918 4101 checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 4102 + 4103 + [[package]] 4104 + name = "windows_x86_64_msvc" 4105 + version = "0.42.2" 4106 + source = "registry+https://github.com/rust-lang/crates.io-index" 4107 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 3919 4108 3920 4109 [[package]] 3921 4110 name = "windows_x86_64_msvc"
+3
crates/jacquard-common/src/session.rs
··· 94 94 impl FileTokenStore { 95 95 /// Create a new file token store at the given path. 96 96 pub fn new(path: impl AsRef<Path>) -> Self { 97 + std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap(); 98 + std::fs::write(path.as_ref(), b"{}").unwrap(); 99 + 97 100 Self { 98 101 path: path.as_ref().to_path_buf(), 99 102 }
+31 -19
crates/jacquard-common/src/types/xrpc.rs
··· 280 280 .await 281 281 .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?; 282 282 283 - let status = http_response.status(); 284 - // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 285 - // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 286 - if status.as_u16() == 401 { 287 - if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 288 - return Err(crate::error::ClientError::Auth( 289 - crate::error::AuthError::Other(hv.clone()), 290 - )); 291 - } 292 - } 293 - let buffer = Bytes::from(http_response.into_body()); 283 + process_response(http_response) 284 + } 285 + } 294 286 295 - if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 296 - return Err(crate::error::HttpError { 297 - status, 298 - body: Some(buffer), 299 - } 300 - .into()); 287 + /// Process the HTTP response from the server into a proper xrpc response statelessly. 288 + /// 289 + /// Exposed to make things more easily pluggable 290 + #[inline] 291 + pub fn process_response<R: XrpcRequest + Send>( 292 + http_response: http::Response<Vec<u8>>, 293 + ) -> XrpcResult<Response<R>> { 294 + let status = http_response.status(); 295 + // If the server returned 401 with a WWW-Authenticate header, expose it so higher layers 296 + // (e.g., DPoP handling) can detect `error="invalid_token"` and trigger refresh. 297 + if status.as_u16() == 401 { 298 + if let Some(hv) = http_response.headers().get(http::header::WWW_AUTHENTICATE) { 299 + return Err(crate::error::ClientError::Auth( 300 + crate::error::AuthError::Other(hv.clone()), 301 + )); 301 302 } 303 + } 304 + let buffer = Bytes::from(http_response.into_body()); 302 305 303 - Ok(Response::new(buffer, status)) 306 + if !status.is_success() && !matches!(status.as_u16(), 400 | 401) { 307 + return Err(crate::error::HttpError { 308 + status, 309 + body: Some(buffer), 310 + } 311 + .into()); 304 312 } 313 + 314 + Ok(Response::new(buffer, status)) 305 315 } 306 316 307 317 /// HTTP headers commonly used in XRPC requests ··· 703 713 struct Err<'a>(#[serde(borrow)] CowStr<'a>); 704 714 impl IntoStatic for Err<'_> { 705 715 type Output = Err<'static>; 706 - fn into_static(self) -> Self::Output { Err(self.0.into_static()) } 716 + fn into_static(self) -> Self::Output { 717 + Err(self.0.into_static()) 718 + } 707 719 } 708 720 impl XrpcRequest for Req { 709 721 const NSID: &'static str = "com.example.test";
+8 -1
crates/jacquard-oauth/Cargo.toml
··· 30 30 rand = { version = "0.8.5", features = ["small_rng"] } 31 31 async-trait.workspace = true 32 32 dashmap = "6.1.0" 33 - tokio = { workspace = true, features = ["sync"] } 33 + tokio = { workspace = true, features = ["sync", "net", "time"] } 34 34 reqwest.workspace = true 35 35 trait-variant.workspace = true 36 + webbrowser = { version = "0.8", optional = true } 37 + rouille = { version = "3.6.2", optional = true } 38 + 39 + [features] 40 + default = [] 41 + loopback = ["dep:rouille"] 42 + browser-open = ["dep:webbrowser"]
+16 -12
crates/jacquard-oauth/src/atproto.rs
··· 113 113 if let Some(redirect_uris) = &mut redirect_uris { 114 114 for redirect_uri in redirect_uris { 115 115 let _ = redirect_uri.set_scheme("http"); 116 - redirect_uri.set_host(Some("localhost")).unwrap(); 117 - let _ = redirect_uri.set_port(None); 116 + redirect_uri.set_host(Some("127.0.0.1")).unwrap(); 118 117 } 119 118 } 120 119 // determine client_id ··· 157 156 keyset: &Option<Keyset>, 158 157 ) -> Result<OAuthClientMetadata<'m>> { 159 158 // For non-loopback clients, require a keyset/JWKs. 160 - let is_loopback = metadata.client_id.scheme() == "http" 161 - && metadata.client_id.host_str() == Some("localhost"); 159 + let is_loopback = 160 + metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost"); 162 161 if !is_loopback && keyset.is_none() { 163 162 return Err(Error::EmptyJwks); 164 163 } ··· 192 191 } else { 193 192 None 194 193 }, 195 - scope: if keyset.is_some() { 196 - Some(Scope::serialize_multiple(metadata.scopes.as_slice())) 197 - } else { 198 - None 199 - }, 194 + scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 200 195 dpop_bound_access_tokens: if keyset.is_some() { Some(true) } else { None }, 201 196 jwks_uri, 202 197 jwks, ··· 300 295 assert_eq!( 301 296 out, 302 297 OAuthClientMetadata { 303 - client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(), 298 + client_id: Url::from_str( 299 + "http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F" 300 + ) 301 + .unwrap(), 304 302 client_uri: None, 305 303 redirect_uris: vec![Url::from_str("http://localhost/").unwrap()], 306 304 scope: None, ··· 325 323 assert_eq!( 326 324 out, 327 325 OAuthClientMetadata { 328 - client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(), 326 + client_id: Url::from_str( 327 + "http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F" 328 + ) 329 + .unwrap(), 329 330 client_uri: None, 330 331 redirect_uris: vec![Url::from_str("http://localhost/").unwrap()], 331 332 scope: None, ··· 350 351 assert_eq!( 351 352 out, 352 353 OAuthClientMetadata { 353 - client_id: Url::from_str("http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F").unwrap(), 354 + client_id: Url::from_str( 355 + "http://localhost?redirect_uri=http%3A%2F%2Flocalhost%2F" 356 + ) 357 + .unwrap(), 354 358 client_uri: None, 355 359 redirect_uris: vec![Url::from_str("http://localhost/").unwrap()], 356 360 scope: None,
+86 -19
crates/jacquard-oauth/src/client.rs
··· 15 15 http_client::HttpClient, 16 16 types::{ 17 17 did::Did, 18 - xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest}, 18 + xrpc::{ 19 + CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest, build_http_request, 20 + process_response, 21 + }, 19 22 }, 20 23 }; 21 24 use jacquard_identity::JacquardResolver; ··· 50 53 let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); 51 54 Self { registry, client } 52 55 } 56 + 57 + pub fn new_with_shared( 58 + store: Arc<S>, 59 + client: Arc<T>, 60 + client_data: ClientData<'static>, 61 + ) -> Self { 62 + let registry = Arc::new(SessionRegistry::new_shared( 63 + store, 64 + client.clone(), 65 + client_data, 66 + )); 67 + Self { registry, client } 68 + } 53 69 } 54 70 55 71 impl<T, S> OAuthClient<T, S> ··· 88 104 }; 89 105 let auth_req_info = 90 106 par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?; 107 + // Persist state for callback handling 108 + self.registry 109 + .store 110 + .save_auth_req_info(&auth_req_info) 111 + .await?; 91 112 92 113 #[derive(serde::Serialize)] 93 114 struct Parameters<'s> { ··· 121 142 122 143 if let Some(iss) = params.iss { 123 144 if !crate::resolver::issuer_equivalent(&iss, &metadata.issuer) { 124 - return Err(CallbackError::IssuerMismatch { expected: metadata.issuer.to_string(), got: iss.to_string() }.into()); 145 + return Err(CallbackError::IssuerMismatch { 146 + expected: metadata.issuer.to_string(), 147 + got: iss.to_string(), 148 + } 149 + .into()); 125 150 } 126 151 } else if metadata.authorization_response_iss_parameter_supported == Some(true) { 127 152 return Err(CallbackError::MissingIssuer.into()); ··· 252 277 } 253 278 254 279 pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> { 255 - self.data.read().await.token_set.refresh_token.as_ref().map(|t| AuthorizationToken::Dpop(t.clone())) 280 + self.data 281 + .read() 282 + .await 283 + .token_set 284 + .refresh_token 285 + .as_ref() 286 + .map(|t| AuthorizationToken::Dpop(t.clone())) 287 + } 288 + } 289 + impl<T, S> OAuthSession<T, S> 290 + where 291 + S: ClientAuthStore + Send + Sync + 'static, 292 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 293 + { 294 + pub async fn logout(&self) -> Result<()> { 295 + use crate::request::{OAuthMetadata, revoke}; 296 + let mut data = self.data.write().await; 297 + let meta = 298 + OAuthMetadata::new(self.client.as_ref(), &self.registry.client_data, &data).await?; 299 + if meta.server_metadata.revocation_endpoint.is_some() { 300 + let token = data.token_set.access_token.clone(); 301 + revoke(self.client.as_ref(), &mut data.dpop_data, &token, &meta) 302 + .await 303 + .ok(); 304 + } 305 + // Remove from store 306 + self.registry 307 + .del(&data.account_did, &data.session_id) 308 + .await?; 309 + Ok(()) 310 + } 311 + } 312 + 313 + impl<T, S> OAuthClient<T, S> 314 + where 315 + T: OAuthResolver, 316 + S: ClientAuthStore, 317 + { 318 + pub fn from_session(session: &OAuthSession<T, S>) -> Self { 319 + Self { 320 + registry: session.registry.clone(), 321 + client: session.client.clone(), 322 + } 256 323 } 257 324 } 258 325 impl<T, S> OAuthSession<T, S> ··· 266 333 let data = self.data.read().await; 267 334 (data.account_did.clone(), data.session_id.clone()) 268 335 }; 269 - let refreshed = self 270 - .registry 271 - .as_ref() 272 - .get(&did, &sid, true) 273 - .await?; 336 + let refreshed = self.registry.as_ref().get(&did, &sid, true).await?; 274 337 let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone()); 275 338 // Write back updated session 276 339 *self.data.write().await = refreshed.into_static(); ··· 317 380 request: &R, 318 381 ) -> XrpcResult<Response<R>> { 319 382 let base_uri = self.base_uri(); 320 - let auth = self.access_token().await; 321 383 let mut opts = self.options.read().await.clone(); 322 - opts.auth = Some(auth); 323 - let res = self 384 + opts.auth = Some(self.access_token().await); 385 + let guard = self.data.read().await; 386 + let mut dpop = guard.dpop_data.clone(); 387 + let http_response = self 324 388 .client 325 - .xrpc(base_uri.clone()) 326 - .with_options(opts.clone()) 327 - .send(request) 328 - .await; 389 + .dpop_call(&mut dpop) 390 + .send(build_http_request(&base_uri, request, &opts)?) 391 + .await 392 + .map_err(|e| TransportError::Other(Box::new(e)))?; 393 + let res = process_response(http_response); 329 394 if is_invalid_token_response(&res) { 330 395 opts.auth = Some( 331 396 self.refresh() 332 397 .await 333 398 .map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?, 334 399 ); 335 - self.client 336 - .xrpc(base_uri) 337 - .with_options(opts) 338 - .send(request) 400 + let http_response = self 401 + .client 402 + .dpop_call(&mut dpop) 403 + .send(build_http_request(&base_uri, request, &opts)?) 339 404 .await 405 + .map_err(|e| TransportError::Other(Box::new(e)))?; 406 + process_response(http_response) 340 407 } else { 341 408 res 342 409 }
+3
crates/jacquard-oauth/src/dpop.rs
··· 2 2 use chrono::Utc; 3 3 use http::{Request, Response, header::InvalidHeaderValue}; 4 4 use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient}; 5 + use jacquard_identity::JacquardResolver; 5 6 use jose_jwa::{Algorithm, Signing}; 6 7 use jose_jwk::{Jwk, Key, crypto}; 7 8 use p256::ecdsa::SigningKey; ··· 245 246 claims, 246 247 )?) 247 248 } 249 + 250 + impl DpopExt for JacquardResolver {}
+5 -2
crates/jacquard-oauth/src/error.rs
··· 55 55 /// Typed callback validation errors (redirect handling). 56 56 #[derive(Debug, thiserror::Error, Diagnostic)] 57 57 pub enum CallbackError { 58 - #[error("missing state parameter in callback")] 58 + #[error("missing state parameter in callback")] 59 59 #[diagnostic(code(jacquard_oauth::callback::missing_state))] 60 60 MissingState, 61 - #[error("missing `iss` parameter")] 61 + #[error("missing `iss` parameter")] 62 62 #[diagnostic(code(jacquard_oauth::callback::missing_iss))] 63 63 MissingIssuer, 64 64 #[error("issuer mismatch: expected {expected}, got {got}")] 65 65 #[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))] 66 66 IssuerMismatch { expected: String, got: String }, 67 + #[error("timeout")] 68 + #[diagnostic(code(jacquard_oauth::callback::timeout))] 69 + Timeout, 67 70 } 68 71 69 72 pub type Result<T> = core::result::Result<T, OAuthError>;
+3
crates/jacquard-oauth/src/lib.rs
··· 16 16 pub mod utils; 17 17 18 18 pub const FALLBACK_ALG: &str = "ES256"; 19 + 20 + #[cfg(feature = "loopback")] 21 + pub mod loopback;
+178
crates/jacquard-oauth/src/loopback.rs
··· 1 + #![cfg(feature = "loopback")] 2 + 3 + use crate::{ 4 + atproto::AtprotoClientMetadata, 5 + authstore::ClientAuthStore, 6 + client::OAuthClient, 7 + dpop::DpopExt, 8 + error::{CallbackError, OAuthError}, 9 + resolver::OAuthResolver, 10 + scopes::Scope, 11 + types::{AuthorizeOptions, CallbackParams}, 12 + }; 13 + use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr}; 14 + use rouille::Server; 15 + use std::{net::SocketAddr, sync::Arc}; 16 + use tokio::{ 17 + net::TcpListener, 18 + sync::{Mutex, mpsc, oneshot}, 19 + }; 20 + use url::Url; 21 + 22 + #[derive(Clone, Debug)] 23 + pub enum LoopbackPort { 24 + Fixed(u16), 25 + Ephemeral, 26 + } 27 + 28 + #[derive(Clone, Debug)] 29 + pub struct LoopbackConfig { 30 + pub host: String, 31 + pub port: LoopbackPort, 32 + pub open_browser: bool, 33 + pub timeout_ms: u64, 34 + } 35 + 36 + impl Default for LoopbackConfig { 37 + fn default() -> Self { 38 + Self { 39 + host: "127.0.0.1".into(), 40 + port: LoopbackPort::Fixed(4000), 41 + open_browser: true, 42 + timeout_ms: 5 * 60 * 1000, 43 + } 44 + } 45 + } 46 + 47 + #[cfg(feature = "browser-open")] 48 + fn try_open_in_browser(url: &str) -> bool { 49 + webbrowser::open(url).is_ok() 50 + } 51 + #[cfg(not(feature = "browser-open"))] 52 + fn try_open_in_browser(_url: &str) -> bool { 53 + false 54 + } 55 + 56 + pub fn create_callback_router( 57 + request: &rouille::Request, 58 + tx: mpsc::Sender<CallbackParams>, 59 + ) -> rouille::Response { 60 + rouille::router!(request, 61 + (GET) (/oauth/callback) => { 62 + let state = request.get_param("state").unwrap(); 63 + let code = request.get_param("code").unwrap(); 64 + let iss = request.get_param("iss").unwrap(); 65 + let callback_params = CallbackParams { 66 + state: Some(state.to_cowstr().into_static()), 67 + code: code.to_cowstr().into_static(), 68 + iss: Some(iss.to_cowstr().into_static()), 69 + }; 70 + tx.try_send(callback_params).unwrap(); 71 + rouille::Response::text("Logged in!") 72 + }, 73 + _ => rouille::Response::empty_404() 74 + ) 75 + } 76 + 77 + struct CallbackHandle { 78 + #[allow(dead_code)] 79 + server_handle: std::thread::JoinHandle<()>, 80 + server_stop: std::sync::mpsc::Sender<()>, 81 + callback_rx: mpsc::Receiver<CallbackParams<'static>>, 82 + } 83 + 84 + fn one_shot_server(addr: SocketAddr) -> (SocketAddr, CallbackHandle) { 85 + let (tx, callback_rx) = mpsc::channel(5); 86 + let server = Server::new(addr, move |request| { 87 + create_callback_router(request, tx.clone()) 88 + }) 89 + .expect("Could not start server"); 90 + let (server_handle, server_stop) = server.stoppable(); 91 + let handle = CallbackHandle { 92 + server_handle, 93 + server_stop, 94 + callback_rx, 95 + }; 96 + (addr, handle) 97 + } 98 + 99 + impl<T, S> OAuthClient<T, S> 100 + where 101 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 102 + S: ClientAuthStore + Send + Sync + 'static, 103 + { 104 + /// Drive the full OAuth flow using a local loopback server. 105 + pub async fn login_with_local_server( 106 + &self, 107 + input: impl AsRef<str>, 108 + opts: AuthorizeOptions<'_>, 109 + cfg: LoopbackConfig, 110 + ) -> crate::error::Result<super::client::OAuthSession<T, S>> { 111 + // 1) Bind server first to learn effective port 112 + let port = match cfg.port { 113 + LoopbackPort::Fixed(p) => p, 114 + LoopbackPort::Ephemeral => 0, 115 + }; 116 + // TODO: fix this to it also accepts ipv6 117 + let bind_addr: SocketAddr = format!("0.0.0.0:{}", port) 118 + .parse() 119 + .expect("invalid loopback host/port"); 120 + let (local_addr, handle) = one_shot_server(bind_addr); 121 + println!("Listening on {}", local_addr); 122 + 123 + // 2) Build per-flow metadata with the actual redirect URI 124 + let redirect = Url::parse(&format!( 125 + "http://{}:{}/oauth/callback", 126 + cfg.host, 127 + local_addr.port(), 128 + )) 129 + .unwrap(); 130 + let client_data = crate::session::ClientData { 131 + keyset: self.registry.client_data.keyset.clone(), 132 + config: AtprotoClientMetadata::new_localhost( 133 + Some(vec![redirect.clone()]), 134 + Some(vec![ 135 + Scope::Atproto, 136 + Scope::Transition(crate::scopes::TransitionScope::Generic), 137 + ]), 138 + ), 139 + }; 140 + 141 + // Build a per-flow client using shared store and resolver 142 + let flow_client = OAuthClient::new_with_shared( 143 + self.registry.store.clone(), 144 + self.client.clone(), 145 + client_data.clone(), 146 + ); 147 + 148 + // 3) Start auth (persists state) and get authorization URL 149 + let auth_url = flow_client.start_auth(input.as_ref(), opts).await?; 150 + // Print URL for copy/paste 151 + println!("Open this URL to authorize:\n{}\n", auth_url); 152 + // Optionally open browser 153 + if cfg.open_browser { 154 + let _ = try_open_in_browser(&auth_url); 155 + } 156 + 157 + // 4) Await callback or timeout 158 + let mut callback_rx = handle.callback_rx; 159 + let cb = tokio::time::timeout( 160 + std::time::Duration::from_millis(cfg.timeout_ms), 161 + callback_rx.recv(), 162 + ) 163 + .await; 164 + // trigger shutdown 165 + let _ = handle.server_stop.send(()); 166 + if let Err(_) = cb { 167 + return Err(OAuthError::Callback(CallbackError::Timeout)); 168 + } 169 + 170 + if let Ok(Some(cb)) = cb { 171 + // 5) Continue with callback flow 172 + let session = flow_client.callback(cb).await?; 173 + Ok(session) 174 + } else { 175 + Err(OAuthError::Callback(CallbackError::Timeout)) 176 + } 177 + } 178 + }
+2 -1
crates/jacquard-oauth/src/request.rs
··· 383 383 login_hint: login_hint, 384 384 prompt: prompt.map(CowStr::from), 385 385 }; 386 + println!("Parameters: {:?}", parameters); 386 387 if metadata 387 388 .server_metadata 388 389 .pushed_authorization_request_endpoint ··· 509 510 metadata.client_metadata.redirect_uris[0] 510 511 .clone() 511 512 .to_smolstr(), 512 - ), // ? 513 + ), 513 514 code_verifier: verifier.into(), 514 515 }), 515 516 metadata,
+79 -20
crates/jacquard-oauth/src/resolver.rs
··· 1 1 use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 2 2 use http::{Request, StatusCode}; 3 - use jacquard_common::{IntoStatic, error::TransportError}; 3 + use jacquard_common::CowStr; 4 4 use jacquard_common::types::did_doc::DidDocument; 5 5 use jacquard_common::types::ident::AtIdentifier; 6 + use jacquard_common::{IntoStatic, error::TransportError}; 6 7 use jacquard_common::{http_client::HttpClient, types::did::Did}; 7 8 use jacquard_identity::resolver::{IdentityError, IdentityResolver}; 8 9 use url::Url; ··· 50 51 #[derive(thiserror::Error, Debug, miette::Diagnostic)] 51 52 pub enum ResolverError { 52 53 #[error("resource not found")] 53 - #[diagnostic(code(jacquard_oauth::resolver::not_found), help("check the base URL or identifier"))] 54 + #[diagnostic( 55 + code(jacquard_oauth::resolver::not_found), 56 + help("check the base URL or identifier") 57 + )] 54 58 NotFound, 55 59 #[error("invalid at identifier: {0}")] 56 - #[diagnostic(code(jacquard_oauth::resolver::at_identifier), help("ensure a valid handle or DID was provided"))] 60 + #[diagnostic( 61 + code(jacquard_oauth::resolver::at_identifier), 62 + help("ensure a valid handle or DID was provided") 63 + )] 57 64 AtIdentifier(String), 58 65 #[error("invalid did: {0}")] 59 - #[diagnostic(code(jacquard_oauth::resolver::did), help("ensure DID is correctly formed (did:plc or did:web)"))] 66 + #[diagnostic( 67 + code(jacquard_oauth::resolver::did), 68 + help("ensure DID is correctly formed (did:plc or did:web)") 69 + )] 60 70 Did(String), 61 71 #[error("invalid did document: {0}")] 62 - #[diagnostic(code(jacquard_oauth::resolver::did_document), help("verify the DID document structure and service entries"))] 72 + #[diagnostic( 73 + code(jacquard_oauth::resolver::did_document), 74 + help("verify the DID document structure and service entries") 75 + )] 63 76 DidDocument(String), 64 77 #[error("protected resource metadata is invalid: {0}")] 65 - #[diagnostic(code(jacquard_oauth::resolver::protected_resource_metadata), help("PDS must advertise an authorization server in its protected resource metadata"))] 78 + #[diagnostic( 79 + code(jacquard_oauth::resolver::protected_resource_metadata), 80 + help("PDS must advertise an authorization server in its protected resource metadata") 81 + )] 66 82 ProtectedResourceMetadata(String), 67 83 #[error("authorization server metadata is invalid: {0}")] 68 - #[diagnostic(code(jacquard_oauth::resolver::authorization_server_metadata), help("issuer must match and include the PDS resource"))] 84 + #[diagnostic( 85 + code(jacquard_oauth::resolver::authorization_server_metadata), 86 + help("issuer must match and include the PDS resource") 87 + )] 69 88 AuthorizationServerMetadata(String), 70 89 #[error("error resolving identity: {0}")] 71 90 #[diagnostic(code(jacquard_oauth::resolver::identity))] 72 91 IdentityResolverError(#[from] IdentityError), 73 92 #[error("unsupported did method: {0:?}")] 74 - #[diagnostic(code(jacquard_oauth::resolver::unsupported_did_method), help("supported DID methods: did:web, did:plc"))] 93 + #[diagnostic( 94 + code(jacquard_oauth::resolver::unsupported_did_method), 95 + help("supported DID methods: did:web, did:plc") 96 + )] 75 97 UnsupportedDidMethod(Did<'static>), 76 98 #[error(transparent)] 77 99 #[diagnostic(code(jacquard_oauth::resolver::transport))] 78 100 Transport(#[from] TransportError), 79 101 #[error("http status: {0:?}")] 80 - #[diagnostic(code(jacquard_oauth::resolver::http_status), help("check well-known paths and server configuration"))] 102 + #[diagnostic( 103 + code(jacquard_oauth::resolver::http_status), 104 + help("check well-known paths and server configuration") 105 + )] 81 106 HttpStatus(StatusCode), 82 107 #[error(transparent)] 83 108 #[diagnostic(code(jacquard_oauth::resolver::serde_json))] ··· 194 219 let as_metadata = self.get_authorization_server_metadata(issuer).await?; 195 220 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 196 221 if let Some(protected_resources) = &as_metadata.protected_resources { 197 - if !protected_resources.contains(&rs_metadata.resource) { 222 + let resource_url = rs_metadata 223 + .resource 224 + .strip_suffix('/') 225 + .unwrap_or(rs_metadata.resource.as_str()); 226 + if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 198 227 return Err(ResolverError::AuthorizationServerMetadata(format!( 199 - "pds {pds} does not protected by issuer: {issuer}", 228 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 229 + rs_metadata.resource, protected_resources 200 230 ))); 201 231 } 202 232 } ··· 316 346 #[tokio::test] 317 347 async fn authorization_server_http_status() { 318 348 let client = MockHttp::default(); 319 - *client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::NOT_FOUND).body(Vec::new()).unwrap()); 349 + *client.next.lock().await = Some( 350 + HttpResponse::builder() 351 + .status(StatusCode::NOT_FOUND) 352 + .body(Vec::new()) 353 + .unwrap(), 354 + ); 320 355 let issuer = url::Url::parse("https://issuer").unwrap(); 321 - let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err(); 356 + let err = super::resolve_authorization_server(&client, &issuer) 357 + .await 358 + .unwrap_err(); 322 359 matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND)); 323 360 } 324 361 325 362 #[tokio::test] 326 363 async fn authorization_server_bad_json() { 327 364 let client = MockHttp::default(); 328 - *client.next.lock().await = Some(HttpResponse::builder().status(StatusCode::OK).body(b"{not json}".to_vec()).unwrap()); 365 + *client.next.lock().await = Some( 366 + HttpResponse::builder() 367 + .status(StatusCode::OK) 368 + .body(b"{not json}".to_vec()) 369 + .unwrap(), 370 + ); 329 371 let issuer = url::Url::parse("https://issuer").unwrap(); 330 - let err = super::resolve_authorization_server(&client, &issuer).await.unwrap_err(); 372 + let err = super::resolve_authorization_server(&client, &issuer) 373 + .await 374 + .unwrap_err(); 331 375 matches!(err, ResolverError::SerdeJson(_)); 332 376 } 333 377 334 378 #[test] 335 379 fn issuer_equivalence_rules() { 336 - assert!(super::issuer_equivalent("https://issuer", "https://issuer/")); 337 - assert!(super::issuer_equivalent("https://issuer:443/", "https://issuer/")); 338 - assert!(!super::issuer_equivalent("http://issuer/", "https://issuer/")); 339 - assert!(!super::issuer_equivalent("https://issuer/foo", "https://issuer/")); 340 - assert!(!super::issuer_equivalent("https://issuer/?q=1", "https://issuer/")); 380 + assert!(super::issuer_equivalent( 381 + "https://issuer", 382 + "https://issuer/" 383 + )); 384 + assert!(super::issuer_equivalent( 385 + "https://issuer:443/", 386 + "https://issuer/" 387 + )); 388 + assert!(!super::issuer_equivalent( 389 + "http://issuer/", 390 + "https://issuer/" 391 + )); 392 + assert!(!super::issuer_equivalent( 393 + "https://issuer/foo", 394 + "https://issuer/" 395 + )); 396 + assert!(!super::issuer_equivalent( 397 + "https://issuer/?q=1", 398 + "https://issuer/" 399 + )); 341 400 } 342 401 }
+9
crates/jacquard-oauth/src/session.rs
··· 310 310 pending: DashMap::new(), 311 311 } 312 312 } 313 + 314 + pub fn new_shared(store: Arc<S>, client: Arc<T>, client_data: ClientData<'static>) -> Self { 315 + Self { 316 + store, 317 + client, 318 + client_data, 319 + pending: DashMap::new(), 320 + } 321 + } 313 322 } 314 323 315 324 impl<T, S> SessionRegistry<T, S>
+4 -4
crates/jacquard-oauth/src/types/request.rs
··· 1 1 use jacquard_common::{CowStr, IntoStatic}; 2 2 use serde::{Deserialize, Serialize}; 3 3 4 - #[derive(Serialize, Deserialize)] 4 + #[derive(Serialize, Deserialize, Debug)] 5 5 #[serde(rename_all = "snake_case")] 6 6 pub enum AuthorizationResponseType { 7 7 Code, ··· 10 10 IdToken, 11 11 } 12 12 13 - #[derive(Serialize, Deserialize)] 13 + #[derive(Serialize, Deserialize, Debug)] 14 14 #[serde(rename_all = "snake_case")] 15 15 pub enum AuthorizationResponseMode { 16 16 Query, ··· 19 19 FormPost, 20 20 } 21 21 22 - #[derive(Serialize, Deserialize)] 22 + #[derive(Serialize, Deserialize, Debug)] 23 23 pub enum AuthorizationCodeChallengeMethod { 24 24 S256, 25 25 #[serde(rename = "plain")] 26 26 Plain, 27 27 } 28 28 29 - #[derive(Serialize, Deserialize)] 29 + #[derive(Serialize, Deserialize, Debug)] 30 30 pub struct ParParameters<'a> { 31 31 // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 32 32 pub response_type: AuthorizationResponseType,
+2 -2
crates/jacquard/Cargo.toml
··· 18 18 api_all = ["api", "jacquard-api/app_bsky", "jacquard-api/chat_bsky", "jacquard-api/tools_ozone"] 19 19 dns = ["jacquard-identity/dns"] 20 20 fancy = ["miette/fancy"] 21 - loopback = ["dep:rouille"] 21 + # Propagate loopback to oauth (server + browser helper) 22 + loopback = ["jacquard-oauth/loopback", "jacquard-oauth/browser-open"] 22 23 23 24 [lib] 24 25 name = "jacquard" ··· 56 57 jose-jwk = { workspace = true, features = ["p256"] } 57 58 p256 = { workspace = true, features = ["ecdsa"] } 58 59 rand_core.workspace = true 59 - rouille = { version = "3.6.2", optional = true }
+39 -30
crates/jacquard/src/main.rs
··· 1 1 use clap::Parser; 2 2 use jacquard::CowStr; 3 - use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 - use jacquard::client::credential_session::{CredentialSession, SessionKey}; 5 - use jacquard::client::{AtpSession, MemorySessionStore}; 3 + use jacquard::client::{Agent, FileAuthStore}; 6 4 use jacquard::types::xrpc::XrpcClient; 7 - use jacquard_identity::slingshot_resolver_default; 5 + use jacquard_api::app_bsky::feed::get_timeline::GetTimeline; 6 + use jacquard_oauth::atproto::AtprotoClientMetadata; 7 + use jacquard_oauth::client::OAuthClient; 8 + #[cfg(feature = "loopback")] 9 + use jacquard_oauth::loopback::LoopbackConfig; 10 + use jacquard_oauth::scopes::Scope; 8 11 use miette::IntoDiagnostic; 9 - use std::sync::Arc; 10 12 11 13 #[derive(Parser, Debug)] 12 - #[command(author, version, about = "Jacquard - AT Protocol client demo")] 14 + #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] 13 15 struct Args { 14 - /// Username/handle (e.g., alice.bsky.social) or DID 15 - #[arg(short, long)] 16 - username: CowStr<'static>, 16 + /// Handle (e.g., alice.bsky.social), DID, or PDS URL 17 + input: CowStr<'static>, 17 18 18 - /// App password 19 - #[arg(short, long)] 20 - password: CowStr<'static>, 19 + /// Path to auth store file (will be created if missing) 20 + #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 21 + store: String, 21 22 } 23 + 22 24 #[tokio::main] 23 25 async fn main() -> miette::Result<()> { 24 26 let args = Args::parse(); 25 27 26 - // Resolver + in-memory store 27 - let resolver = Arc::new(slingshot_resolver_default()); 28 - let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 29 - let client = Arc::new(resolver.clone()); 30 - let session = CredentialSession::new(store, client); 28 + // File-backed auth store shared by OAuthClient and session registry 29 + let store = FileAuthStore::new(&args.store); 30 + 31 + // Minimal localhost client metadata (redirect_uris get set by loopback helper) 32 + let client_data = jacquard_oauth::session::ClientData { 33 + keyset: None, 34 + // scopes: include atproto; redirect_uris will be populated by the loopback helper 35 + config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])), 36 + }; 37 + 38 + // Build an OAuth client and run loopback flow 39 + let oauth = OAuthClient::new(store, client_data); 31 40 32 - let _ = session 33 - .login( 34 - args.username.clone(), 35 - args.password.clone(), 36 - None, 37 - None, 38 - None, 41 + #[cfg(feature = "loopback")] 42 + let session = oauth 43 + .login_with_local_server( 44 + args.input.clone(), 45 + Default::default(), 46 + LoopbackConfig::default(), 39 47 ) 40 48 .await 41 49 .into_diagnostic()?; 42 50 43 - // Fetch timeline 44 - let timeline = session 51 + #[cfg(not(feature = "loopback"))] 52 + compile_error!("loopback feature must be enabled to run this example"); 53 + 54 + // Wrap in Agent and call a simple resource endpoint 55 + let agent: Agent<_> = Agent::from(session); 56 + let timeline = agent 45 57 .send(&GetTimeline::new().limit(5).build()) 46 58 .await 47 59 .into_diagnostic()? 48 - .into_output() 49 - .into_diagnostic()?; 50 - 51 - println!("\ntimeline ({} posts):", timeline.feed.len()); 60 + .into_output()?; 52 61 for (i, post) in timeline.feed.iter().enumerate() { 53 62 println!("\n{}. by {}", i + 1, post.post.author.handle); 54 63 println!(