+190
-1
Cargo.lock
+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
+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
+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
+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
+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
+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
+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
+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
+3
crates/jacquard-oauth/src/lib.rs
+178
crates/jacquard-oauth/src/loopback.rs
+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
+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
+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
+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
+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
+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
+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!(