A better Rust ATProto crate
0
fork

Configure Feed

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

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

Orual 2ff4604b 2112129b

+658 -111
+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!(