+58
-41
README.md
+58
-41
README.md
···
2
2
3
3
A suite of Rust crates for the AT Protocol.
4
4
5
-
[](https://crates.io/crates/jacquard) [](https://docs.rs/jacquard) [](./LICENSE)
5
+
## Goals and Features
6
6
7
-
## Goals
8
-
9
-
- Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris)
7
+
- Validated, spec-compliant, easy to work with, and performant baseline types
10
8
- Batteries-included, but easily replaceable batteries.
11
-
- Easy to extend with custom lexicons
9
+
- Easy to extend with custom lexicons
10
+
- Straightforward OAuth
11
+
- stateless options (or options where you handle the state) for rolling your own
12
+
- all the building blocks of the convenient abstractions are available
12
13
- lexicon Value type for working with unknown atproto data (dag-cbor or json)
13
14
- order of magnitude less boilerplate than some existing crates
14
-
- either the codegen produces code that's easy to work with, or there are good handwritten wrappers
15
-
- didDoc type with helper methods for getting handles, multikey, and PDS endpoint
16
15
- use as much or as little from the crates as you need
17
16
18
-
19
17
## Example
20
18
21
-
Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline.
19
+
Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline.
22
20
23
21
```rust
24
-
use std::sync::Arc;
22
+
// Note: this requires the `loopback` feature enabled (it is currently by default)
25
23
use clap::Parser;
26
24
use jacquard::CowStr;
27
25
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
28
-
use jacquard::client::credential_session::{CredentialSession, SessionKey};
29
-
use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
30
-
use jacquard::identity::PublicResolver as JacquardResolver;
26
+
use jacquard::client::{Agent, FileAuthStore};
27
+
use jacquard::oauth::atproto::AtprotoClientMetadata;
28
+
use jacquard::oauth::client::OAuthClient;
29
+
use jacquard::oauth::loopback::LoopbackConfig;
30
+
use jacquard::oauth::scopes::Scope;
31
+
use jacquard::types::xrpc::XrpcClient;
31
32
use miette::IntoDiagnostic;
32
33
33
34
#[derive(Parser, Debug)]
34
-
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
35
+
#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
35
36
struct Args {
36
-
/// Username/handle (e.g., alice.bsky.social) or DID
37
-
#[arg(short, long)]
38
-
username: CowStr<'static>,
39
-
/// App password
40
-
#[arg(short, long)]
41
-
password: CowStr<'static>,
37
+
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
38
+
input: CowStr<'static>,
39
+
40
+
/// Path to auth store file (will be created if missing)
41
+
#[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
42
+
store: String,
42
43
}
43
44
44
45
#[tokio::main]
45
46
async fn main() -> miette::Result<()> {
46
47
let args = Args::parse();
47
48
48
-
// Resolver + storage
49
-
let resolver = Arc::new(JacquardResolver::default());
50
-
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
51
-
let client = Arc::new(resolver.clone());
52
-
let session = CredentialSession::new(store, client);
49
+
// File-backed auth store for testing
50
+
let store = FileAuthStore::new(&args.store);
51
+
let client_data = jacquard_oauth::session::ClientData {
52
+
keyset: None,
53
+
// Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
54
+
// The localhost helper will ensure you have at least "atproto" and will fix urls
55
+
config: AtprotoClientMetadata::default_localhost()
56
+
};
53
57
54
-
// Login (resolves PDS automatically) and persist as (did, "session")
55
-
session
56
-
.login(args.username.clone(), args.password.clone(), None, None, None)
57
-
.await
58
-
.into_diagnostic()?;
59
-
60
-
// Fetch timeline
61
-
let timeline = session
62
-
.clone()
63
-
.send(GetTimeline::new().limit(5).build())
64
-
.await
65
-
.into_diagnostic()?
66
-
.into_output()
67
-
.into_diagnostic()?;
68
-
69
-
println!("\ntimeline ({} posts):", timeline.feed.len());
58
+
// Build an OAuth client
59
+
let oauth = OAuthClient::new(store, client_data);
60
+
// Authenticate with a PDS, using a loopback server to handle the callback flow
61
+
let session = oauth
62
+
.login_with_local_server(
63
+
args.input.clone(),
64
+
Default::default(),
65
+
LoopbackConfig::default(),
66
+
)
67
+
.await?;
68
+
// Wrap in Agent and fetch the timeline
69
+
let agent: Agent<_> = Agent::from(session);
70
+
let timeline = agent
71
+
.send(&GetTimeline::new().limit(5).build())
72
+
.await?
73
+
.into_output()?;
70
74
for (i, post) in timeline.feed.iter().enumerate() {
71
75
println!("\n{}. by {}", i + 1, post.post.author.handle);
72
76
println!(
···
77
81
78
82
Ok(())
79
83
}
84
+
80
85
```
81
86
87
+
## Component crates
88
+
89
+
Jacquard is broken up into several crates for modularity. The correct one to use is generally `jacquard` itself, as it re-exports the others.
90
+
- `jacquard`: Main crate [](https://crates.io/crates/jacquard) [](https://docs.rs/jacquard)
91
+
- `jacquard-api`: Autogenerated API bindings [](https://crates.io/crates/jacquard-api) [](https://docs.rs/jacquard-api)
92
+
- `jacquard-oauth`: atproto OAuth implementation [](https://crates.io/crates/jacquard-oauth) [](https://docs.rs/jacquard-oauth)
93
+
- `jacquard-identity`: Identity resolution [](https://crates.io/crates/jacquard-identity) [](https://docs.rs/jacquard-identity)
94
+
- `jacquard-lexicon`: Lexicon parsing and code generation [](https://crates.io/crates/jacquard-lexicon) [](https://docs.rs/jacquard-lexicon)
95
+
- `jacquard-derive`: Derive macros for lexicon types [](https://crates.io/crates/jacquard-derive) [](https://docs.rs/jacquard-derive)
96
+
82
97
## Development
83
98
84
99
This repo uses [Flakes](https://nixos.asia/en/flakes) from the get-go.
···
95
110
```
96
111
97
112
There's also a [`justfile`](https://just.systems/) for Makefile-esque commands to be run inside of the devShell, and you can generally `cargo ...` or `just ...` whatever just fine if you don't want to use Nix and have the prerequisites installed.
113
+
114
+
[](./LICENSE)
+7
crates/jacquard-oauth/src/atproto.rs
+7
crates/jacquard-oauth/src/atproto.rs
···
105
105
}
106
106
}
107
107
108
+
pub fn default_localhost() -> Self {
109
+
Self::new_localhost(
110
+
None,
111
+
Some(Scope::parse_multiple("atproto transition:generic").unwrap()),
112
+
)
113
+
}
114
+
108
115
pub fn new_localhost(
109
116
mut redirect_uris: Option<Vec<Url>>,
110
117
scopes: Option<Vec<Scope<'m>>>,
+176
-176
crates/jacquard-oauth/src/request.rs
+176
-176
crates/jacquard-oauth/src/request.rs
···
141
141
}
142
142
}
143
143
144
-
#[cfg(test)]
145
-
mod tests {
146
-
use super::*;
147
-
use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata};
148
-
use bytes::Bytes;
149
-
use http::{Response as HttpResponse, StatusCode};
150
-
use jacquard_common::http_client::HttpClient;
151
-
use jacquard_identity::resolver::IdentityResolver;
152
-
use std::sync::Arc;
153
-
use tokio::sync::Mutex;
154
-
155
-
#[derive(Clone, Default)]
156
-
struct MockClient {
157
-
resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
158
-
}
159
-
160
-
impl HttpClient for MockClient {
161
-
type Error = std::convert::Infallible;
162
-
fn send_http(
163
-
&self,
164
-
_request: http::Request<Vec<u8>>,
165
-
) -> impl core::future::Future<
166
-
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
167
-
> + Send {
168
-
let resp = self.resp.clone();
169
-
async move { Ok(resp.lock().await.take().unwrap()) }
170
-
}
171
-
}
172
-
173
-
// IdentityResolver methods won't be called in these tests; provide stubs.
174
-
#[async_trait::async_trait]
175
-
impl IdentityResolver for MockClient {
176
-
fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
177
-
use std::sync::LazyLock;
178
-
static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> =
179
-
LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default());
180
-
&OPTS
181
-
}
182
-
async fn resolve_handle(
183
-
&self,
184
-
_handle: &jacquard_common::types::string::Handle<'_>,
185
-
) -> std::result::Result<
186
-
jacquard_common::types::string::Did<'static>,
187
-
jacquard_identity::resolver::IdentityError,
188
-
> {
189
-
Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap())
190
-
}
191
-
async fn resolve_did_doc(
192
-
&self,
193
-
_did: &jacquard_common::types::string::Did<'_>,
194
-
) -> std::result::Result<
195
-
jacquard_identity::resolver::DidDocResponse,
196
-
jacquard_identity::resolver::IdentityError,
197
-
> {
198
-
let doc = serde_json::json!({
199
-
"id": "did:plc:alice",
200
-
"service": [{
201
-
"id": "#pds",
202
-
"type": "AtprotoPersonalDataServer",
203
-
"serviceEndpoint": "https://pds"
204
-
}]
205
-
});
206
-
let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
207
-
Ok(jacquard_identity::resolver::DidDocResponse {
208
-
buffer: buf,
209
-
status: StatusCode::OK,
210
-
requested: None,
211
-
})
212
-
}
213
-
}
214
-
215
-
// Allow using DPoP helpers on MockClient
216
-
impl crate::dpop::DpopExt for MockClient {}
217
-
impl crate::resolver::OAuthResolver for MockClient {}
218
-
219
-
fn base_metadata() -> OAuthMetadata {
220
-
let mut server = OAuthAuthorizationServerMetadata::default();
221
-
server.issuer = CowStr::from("https://issuer");
222
-
server.authorization_endpoint = CowStr::from("https://issuer/authorize");
223
-
server.token_endpoint = CowStr::from("https://issuer/token");
224
-
OAuthMetadata {
225
-
server_metadata: server,
226
-
client_metadata: OAuthClientMetadata {
227
-
client_id: url::Url::parse("https://client").unwrap(),
228
-
client_uri: None,
229
-
redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()],
230
-
scope: Some(CowStr::from("atproto")),
231
-
grant_types: None,
232
-
token_endpoint_auth_method: Some(CowStr::from("none")),
233
-
dpop_bound_access_tokens: None,
234
-
jwks_uri: None,
235
-
jwks: None,
236
-
token_endpoint_auth_signing_alg: None,
237
-
},
238
-
keyset: None,
239
-
}
240
-
}
241
-
242
-
#[tokio::test]
243
-
async fn par_missing_endpoint() {
244
-
let mut meta = base_metadata();
245
-
meta.server_metadata.require_pushed_authorization_requests = Some(true);
246
-
meta.server_metadata.pushed_authorization_request_endpoint = None;
247
-
// require_pushed_authorization_requests is true and no endpoint
248
-
let err = super::par(&MockClient::default(), None, None, &meta)
249
-
.await
250
-
.unwrap_err();
251
-
match err {
252
-
RequestError::NoEndpoint(name) => {
253
-
assert_eq!(name.as_ref(), "pushed_authorization_request");
254
-
}
255
-
other => panic!("unexpected: {other:?}"),
256
-
}
257
-
}
258
-
259
-
#[tokio::test]
260
-
async fn refresh_no_refresh_token() {
261
-
let client = MockClient::default();
262
-
let meta = base_metadata();
263
-
let session = ClientSessionData {
264
-
account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
265
-
session_id: CowStr::from("state"),
266
-
host_url: url::Url::parse("https://pds").unwrap(),
267
-
authserver_url: url::Url::parse("https://issuer").unwrap(),
268
-
authserver_token_endpoint: CowStr::from("https://issuer/token"),
269
-
authserver_revocation_endpoint: None,
270
-
scopes: vec![],
271
-
dpop_data: DpopClientData {
272
-
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
273
-
dpop_authserver_nonce: CowStr::from(""),
274
-
dpop_host_nonce: CowStr::from(""),
275
-
},
276
-
token_set: crate::types::TokenSet {
277
-
iss: CowStr::from("https://issuer"),
278
-
sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
279
-
aud: CowStr::from("https://pds"),
280
-
scope: None,
281
-
refresh_token: None,
282
-
access_token: CowStr::from("abc"),
283
-
token_type: crate::types::OAuthTokenType::DPoP,
284
-
expires_at: None,
285
-
},
286
-
};
287
-
let err = super::refresh(&client, session, &meta).await.unwrap_err();
288
-
matches!(err, RequestError::NoRefreshToken);
289
-
}
290
-
291
-
#[tokio::test]
292
-
async fn exchange_code_missing_sub() {
293
-
let client = MockClient::default();
294
-
// set mock HTTP response body: token response without `sub`
295
-
*client.resp.lock().await = Some(
296
-
HttpResponse::builder()
297
-
.status(StatusCode::OK)
298
-
.body(
299
-
serde_json::to_vec(&serde_json::json!({
300
-
"access_token":"tok",
301
-
"token_type":"DPoP",
302
-
"expires_in": 3600
303
-
}))
304
-
.unwrap(),
305
-
)
306
-
.unwrap(),
307
-
);
308
-
let meta = base_metadata();
309
-
let mut dpop = DpopReqData {
310
-
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
311
-
dpop_authserver_nonce: None,
312
-
};
313
-
let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta)
314
-
.await
315
-
.unwrap_err();
316
-
matches!(err, RequestError::TokenVerification);
317
-
}
318
-
}
319
-
320
144
#[derive(Debug, Serialize)]
321
145
pub struct RequestPayload<'a, T>
322
146
where
···
735
559
736
560
Err(RequestError::UnsupportedAuthMethod)
737
561
}
562
+
563
+
#[cfg(test)]
564
+
mod tests {
565
+
use super::*;
566
+
use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata};
567
+
use bytes::Bytes;
568
+
use http::{Response as HttpResponse, StatusCode};
569
+
use jacquard_common::http_client::HttpClient;
570
+
use jacquard_identity::resolver::IdentityResolver;
571
+
use std::sync::Arc;
572
+
use tokio::sync::Mutex;
573
+
574
+
#[derive(Clone, Default)]
575
+
struct MockClient {
576
+
resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
577
+
}
578
+
579
+
impl HttpClient for MockClient {
580
+
type Error = std::convert::Infallible;
581
+
fn send_http(
582
+
&self,
583
+
_request: http::Request<Vec<u8>>,
584
+
) -> impl core::future::Future<
585
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
586
+
> + Send {
587
+
let resp = self.resp.clone();
588
+
async move { Ok(resp.lock().await.take().unwrap()) }
589
+
}
590
+
}
591
+
592
+
// IdentityResolver methods won't be called in these tests; provide stubs.
593
+
#[async_trait::async_trait]
594
+
impl IdentityResolver for MockClient {
595
+
fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
596
+
use std::sync::LazyLock;
597
+
static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> =
598
+
LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default());
599
+
&OPTS
600
+
}
601
+
async fn resolve_handle(
602
+
&self,
603
+
_handle: &jacquard_common::types::string::Handle<'_>,
604
+
) -> std::result::Result<
605
+
jacquard_common::types::string::Did<'static>,
606
+
jacquard_identity::resolver::IdentityError,
607
+
> {
608
+
Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap())
609
+
}
610
+
async fn resolve_did_doc(
611
+
&self,
612
+
_did: &jacquard_common::types::string::Did<'_>,
613
+
) -> std::result::Result<
614
+
jacquard_identity::resolver::DidDocResponse,
615
+
jacquard_identity::resolver::IdentityError,
616
+
> {
617
+
let doc = serde_json::json!({
618
+
"id": "did:plc:alice",
619
+
"service": [{
620
+
"id": "#pds",
621
+
"type": "AtprotoPersonalDataServer",
622
+
"serviceEndpoint": "https://pds"
623
+
}]
624
+
});
625
+
let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
626
+
Ok(jacquard_identity::resolver::DidDocResponse {
627
+
buffer: buf,
628
+
status: StatusCode::OK,
629
+
requested: None,
630
+
})
631
+
}
632
+
}
633
+
634
+
// Allow using DPoP helpers on MockClient
635
+
impl crate::dpop::DpopExt for MockClient {}
636
+
impl crate::resolver::OAuthResolver for MockClient {}
637
+
638
+
fn base_metadata() -> OAuthMetadata {
639
+
let mut server = OAuthAuthorizationServerMetadata::default();
640
+
server.issuer = CowStr::from("https://issuer");
641
+
server.authorization_endpoint = CowStr::from("https://issuer/authorize");
642
+
server.token_endpoint = CowStr::from("https://issuer/token");
643
+
OAuthMetadata {
644
+
server_metadata: server,
645
+
client_metadata: OAuthClientMetadata {
646
+
client_id: url::Url::parse("https://client").unwrap(),
647
+
client_uri: None,
648
+
redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()],
649
+
scope: Some(CowStr::from("atproto")),
650
+
grant_types: None,
651
+
token_endpoint_auth_method: Some(CowStr::from("none")),
652
+
dpop_bound_access_tokens: None,
653
+
jwks_uri: None,
654
+
jwks: None,
655
+
token_endpoint_auth_signing_alg: None,
656
+
},
657
+
keyset: None,
658
+
}
659
+
}
660
+
661
+
#[tokio::test]
662
+
async fn par_missing_endpoint() {
663
+
let mut meta = base_metadata();
664
+
meta.server_metadata.require_pushed_authorization_requests = Some(true);
665
+
meta.server_metadata.pushed_authorization_request_endpoint = None;
666
+
// require_pushed_authorization_requests is true and no endpoint
667
+
let err = super::par(&MockClient::default(), None, None, &meta)
668
+
.await
669
+
.unwrap_err();
670
+
match err {
671
+
RequestError::NoEndpoint(name) => {
672
+
assert_eq!(name.as_ref(), "pushed_authorization_request");
673
+
}
674
+
other => panic!("unexpected: {other:?}"),
675
+
}
676
+
}
677
+
678
+
#[tokio::test]
679
+
async fn refresh_no_refresh_token() {
680
+
let client = MockClient::default();
681
+
let meta = base_metadata();
682
+
let session = ClientSessionData {
683
+
account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
684
+
session_id: CowStr::from("state"),
685
+
host_url: url::Url::parse("https://pds").unwrap(),
686
+
authserver_url: url::Url::parse("https://issuer").unwrap(),
687
+
authserver_token_endpoint: CowStr::from("https://issuer/token"),
688
+
authserver_revocation_endpoint: None,
689
+
scopes: vec![],
690
+
dpop_data: DpopClientData {
691
+
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
692
+
dpop_authserver_nonce: CowStr::from(""),
693
+
dpop_host_nonce: CowStr::from(""),
694
+
},
695
+
token_set: crate::types::TokenSet {
696
+
iss: CowStr::from("https://issuer"),
697
+
sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
698
+
aud: CowStr::from("https://pds"),
699
+
scope: None,
700
+
refresh_token: None,
701
+
access_token: CowStr::from("abc"),
702
+
token_type: crate::types::OAuthTokenType::DPoP,
703
+
expires_at: None,
704
+
},
705
+
};
706
+
let err = super::refresh(&client, session, &meta).await.unwrap_err();
707
+
matches!(err, RequestError::NoRefreshToken);
708
+
}
709
+
710
+
#[tokio::test]
711
+
async fn exchange_code_missing_sub() {
712
+
let client = MockClient::default();
713
+
// set mock HTTP response body: token response without `sub`
714
+
*client.resp.lock().await = Some(
715
+
HttpResponse::builder()
716
+
.status(StatusCode::OK)
717
+
.body(
718
+
serde_json::to_vec(&serde_json::json!({
719
+
"access_token":"tok",
720
+
"token_type":"DPoP",
721
+
"expires_in": 3600
722
+
}))
723
+
.unwrap(),
724
+
)
725
+
.unwrap(),
726
+
);
727
+
let meta = base_metadata();
728
+
let mut dpop = DpopReqData {
729
+
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
730
+
dpop_authserver_nonce: None,
731
+
};
732
+
let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta)
733
+
.await
734
+
.unwrap_err();
735
+
matches!(err, RequestError::TokenVerification);
736
+
}
737
+
}
+2
-2
crates/jacquard-oauth/src/scopes.rs
+2
-2
crates/jacquard-oauth/src/scopes.rs
···
1
-
//! AT Protocol OAuth scopes module
2
-
//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
1
+
//! AT Protocol OAuth scopes
2
+
//! Derived from <https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs>
3
3
//!
4
4
//! This module provides comprehensive support for AT Protocol OAuth scopes,
5
5
//! including parsing, serialization, normalization, and permission checking.
+58
-52
crates/jacquard/src/lib.rs
+58
-52
crates/jacquard/src/lib.rs
···
3
3
//! A suite of Rust crates for the AT Protocol.
4
4
//!
5
5
//!
6
-
//! ## Goals
6
+
//! ## Goals and Features
7
7
//!
8
-
//! - Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris)
8
+
//! - Validated, spec-compliant, easy to work with, and performant baseline types
9
9
//! - Batteries-included, but easily replaceable batteries.
10
10
//! - Easy to extend with custom lexicons
11
+
//! - Straightforward OAuth
12
+
//! - stateless options (or options where you handle the state) for rolling your own
13
+
//! - all the building blocks of the convenient abstractions are available
11
14
//! - lexicon Value type for working with unknown atproto data (dag-cbor or json)
12
15
//! - order of magnitude less boilerplate than some existing crates
13
-
//! - either the codegen produces code that's easy to work with, or there are good handwritten wrappers
14
-
//! - didDoc type with helper methods for getting handles, multikey, and PDS endpoint
15
16
//! - use as much or as little from the crates as you need
17
+
//!
16
18
//!
17
19
//!
18
20
//! ## Example
19
21
//!
20
-
//! Dead simple API client: login with an app password, then fetch the latest 5 posts.
22
+
//! Dead simple API client: login with OAuth, then fetch the latest 5 posts.
21
23
//!
22
24
//! ```no_run
23
25
//! # use clap::Parser;
24
26
//! # use jacquard::CowStr;
25
-
//! use std::sync::Arc;
26
27
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
27
28
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
28
-
//! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
29
-
//! use jacquard::identity::PublicResolver as JacquardResolver;
29
+
//! use jacquard::client::{Agent, FileAuthStore};
30
+
//! use jacquard::oauth::atproto::AtprotoClientMetadata;
31
+
//! use jacquard::oauth::client::OAuthClient;
30
32
//! use jacquard::types::xrpc::XrpcClient;
33
+
//! # #[cfg(feature = "loopback")]
34
+
//! use jacquard::oauth::loopback::LoopbackConfig;
31
35
//! # use miette::IntoDiagnostic;
32
36
//!
33
37
//! # #[derive(Parser, Debug)]
34
-
//! # #[command(author, version, about = "Jacquard - AT Protocol client demo")]
38
+
//! # #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
35
39
//! # struct Args {
36
-
//! # /// Username/handle (e.g., alice.bsky.social) or DID
37
-
//! # #[arg(short, long)]
38
-
//! # username: CowStr<'static>,
40
+
//! # /// Handle (e.g., alice.bsky.social), DID, or PDS URL
41
+
//! # input: CowStr<'static>,
39
42
//! #
40
-
//! # /// App password
41
-
//! # #[arg(short, long)]
42
-
//! # password: CowStr<'static>,
43
+
//! # /// Path to auth store file (will be created if missing)
44
+
//! # #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
45
+
//! # store: String,
43
46
//! # }
44
-
//!
47
+
//! #
45
48
//! #[tokio::main]
46
49
//! async fn main() -> miette::Result<()> {
47
50
//! let args = Args::parse();
48
-
//! // Resolver + storage
49
-
//! let resolver = Arc::new(JacquardResolver::default());
50
-
//! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
51
-
//! let client = Arc::new(resolver.clone());
52
-
//! // Create session object with implicit public appview endpoint until login/restore
53
-
//! let session = CredentialSession::new(store, client);
54
-
//! // Log in (resolves PDS automatically) and persist as (did, "session")
55
-
//! session
56
-
//! .login(args.username.clone(), args.password.clone(), None, None, None)
57
-
//! .await
58
-
//! .into_diagnostic()?;
59
-
//! // Fetch timeline
60
-
//! let timeline = session
51
+
//!
52
+
//! // File-backed auth store shared by OAuthClient and session registry
53
+
//! let store = FileAuthStore::new(&args.store);
54
+
//! let client_data = jacquard_oauth::session::ClientData {
55
+
//! keyset: None,
56
+
//! // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
57
+
//! // The localhost helper will ensure you have at least "atproto" and will fix urls
58
+
//! config: AtprotoClientMetadata::default_localhost(),
59
+
//! };
60
+
//!
61
+
//! // Build an OAuth client (this is reusable, and can create multiple sessions)
62
+
//! let oauth = OAuthClient::new(store, client_data);
63
+
//! // Authenticate with a PDS, using a loopback server to handle the callback flow
64
+
//! # #[cfg(feature = "loopback")]
65
+
//! let session = oauth
66
+
//! .login_with_local_server(
67
+
//! args.input.clone(),
68
+
//! Default::default(),
69
+
//! LoopbackConfig::default(),
70
+
//! )
71
+
//! .await?;
72
+
//! # #[cfg(not(feature = "loopback"))]
73
+
//! # compile_error!("loopback feature must be enabled to run this example");
74
+
//! // Wrap in Agent and fetch the timeline
75
+
//! let agent: Agent<_> = Agent::from(session);
76
+
//! let timeline = agent
61
77
//! .send(&GetTimeline::new().limit(5).build())
62
-
//! .await
63
-
//! .into_diagnostic()?
64
-
//! .into_output()
65
-
//! .into_diagnostic()?;
66
-
//! println!("timeline ({} posts):", timeline.feed.len());
78
+
//! .await?
79
+
//! .into_output()?;
67
80
//! for (i, post) in timeline.feed.iter().enumerate() {
68
-
//! println!("{}. by {}", i + 1, post.post.author.handle);
81
+
//! println!("\n{}. by {}", i + 1, post.post.author.handle);
82
+
//! println!(
83
+
//! " {}",
84
+
//! serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
85
+
//! );
69
86
//! }
70
87
//! Ok(())
71
-
//! }
88
+
//!}
72
89
//! ```
73
90
//!
74
91
//! ## Client options:
···
102
119
//! }
103
120
//! ```
104
121
//! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and
105
-
//! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the
122
+
//! `T: IdentityResolver + HttpClient`. It auto-attaches bearer authorization, refreshes on expiry, and updates the
106
123
//! base endpoint to the user's PDS on login/restore.
124
+
//! - Stateful client (OAuth): `OAuthClient<S, T>` and `OAuthSession<S, T>` where `S: ClientAuthStore` and
125
+
//! `T: OAuthResolver + HttpClient`. The client is used to authenticate, returning a session which handles authentication and token refresh internally.
126
+
//! - `Agent<A: AgentSession>` Session abstracts over the above two options. Currently it is a thin wrapper, but this will be the thing that gets all the convenience helpers.
107
127
//!
108
128
//! Per-request overrides (stateless)
109
129
//! ```no_run
···
135
155
//! Ok(())
136
156
//! }
137
157
//! ```
138
-
//!
139
-
//! Token storage:
140
-
//! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests.
141
-
//! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions
142
-
//! and OAuth `ClientAuthStore` (unified on-disk map).
143
-
//! ```no_run
144
-
//! use std::sync::Arc;
145
-
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
146
-
//! use jacquard::client::{AtpSession, FileAuthStore};
147
-
//! use jacquard::identity::PublicResolver;
148
-
//! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json"));
149
-
//! let client = Arc::new(PublicResolver::default());
150
-
//! let session = CredentialSession::new(store, client);
151
-
//! ```
152
-
//!
153
158
154
159
#![warn(missing_docs)]
155
160
···
167
172
pub use jacquard_derive::*;
168
173
169
174
pub use jacquard_identity as identity;
175
+
pub use jacquard_oauth as oauth;
+35
-16
crates/jacquard/src/main.rs
+35
-16
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;
3
4
use jacquard::client::{Agent, FileAuthStore};
5
+
use jacquard::oauth::atproto::AtprotoClientMetadata;
6
+
use jacquard::oauth::client::OAuthClient;
7
+
#[cfg(feature = "loopback")]
8
+
use jacquard::oauth::loopback::LoopbackConfig;
4
9
use jacquard::types::xrpc::XrpcClient;
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;
10
+
#[cfg(not(feature = "loopback"))]
11
+
use jacquard_oauth::types::AuthorizeOptions;
11
12
use miette::IntoDiagnostic;
12
13
13
14
#[derive(Parser, Debug)]
···
25
26
async fn main() -> miette::Result<()> {
26
27
let args = Args::parse();
27
28
28
-
// File-backed auth store shared by OAuthClient and session registry
29
+
// File-backed auth store for testing
29
30
let store = FileAuthStore::new(&args.store);
30
31
31
32
// Minimal localhost client metadata (redirect_uris get set by loopback helper)
32
33
let client_data = jacquard_oauth::session::ClientData {
33
34
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])),
35
+
// Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
36
+
// The localhost helper will ensure you have at least "atproto" and will fix urls
37
+
config: AtprotoClientMetadata::default_localhost(),
36
38
};
37
39
38
-
// Build an OAuth client and run loopback flow
40
+
// Build an OAuth client
39
41
let oauth = OAuthClient::new(store, client_data);
40
42
41
43
#[cfg(feature = "loopback")]
44
+
// Authenticate with a PDS, using a loopback server to handle the callback flow
42
45
let session = oauth
43
46
.login_with_local_server(
44
47
args.input.clone(),
45
48
Default::default(),
46
49
LoopbackConfig::default(),
47
50
)
48
-
.await
49
-
.into_diagnostic()?;
51
+
.await?;
50
52
51
53
#[cfg(not(feature = "loopback"))]
52
-
compile_error!("loopback feature must be enabled to run this example");
54
+
let session = {
55
+
use std::io::{BufRead, Write, stdin, stdout};
56
+
57
+
let auth_url = oauth
58
+
.start_auth(args.input, AuthorizeOptions::default())
59
+
.await?;
60
+
61
+
println!("To authenticate with your PDS, visit:\n{}\n", auth_url);
62
+
print!("\nPaste the callback url here:");
63
+
stdout().lock().flush().into_diagnostic()?;
64
+
let mut url = String::new();
65
+
stdin().lock().read_line(&mut url).into_diagnostic()?;
66
+
67
+
let uri = url.trim().parse::<http::Uri>().into_diagnostic()?;
68
+
let params =
69
+
serde_html_form::from_str(uri.query().ok_or(miette::miette!("invalid callback url"))?)
70
+
.into_diagnostic()?;
71
+
oauth.callback(params).await?
72
+
};
53
73
54
-
// Wrap in Agent and call a simple resource endpoint
74
+
// Wrap in Agent and fetch the timeline
55
75
let agent: Agent<_> = Agent::from(session);
56
76
let timeline = agent
57
77
.send(&GetTimeline::new().limit(5).build())
58
-
.await
59
-
.into_diagnostic()?
78
+
.await?
60
79
.into_output()?;
61
80
for (i, post) in timeline.feed.iter().enumerate() {
62
81
println!("\n{}. by {}", i + 1, post.post.author.handle);