A better Rust ATProto crate

non-loopback oauth flow in main demo

Orual 1465499e 511f3f8c

Changed files
+336 -287
crates
jacquard
jacquard-oauth
+58 -41
README.md
··· 2 2 3 3 A suite of Rust crates for the AT Protocol. 4 4 5 - [![Crates.io](https://img.shields.io/crates/v/jacquard.svg)](https://crates.io/crates/jacquard) [![Documentation](https://docs.rs/jacquard/badge.svg)](https://docs.rs/jacquard) [![License](https://img.shields.io/crates/l/jacquard.svg)](./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 [![Crates.io](https://img.shields.io/crates/v/jacquard.svg)](https://crates.io/crates/jacquard) [![Documentation](https://docs.rs/jacquard/badge.svg)](https://docs.rs/jacquard) 91 + - `jacquard-api`: Autogenerated API bindings [![Crates.io](https://img.shields.io/crates/v/jacquard-api.svg)](https://crates.io/crates/jacquard-api) [![Documentation](https://docs.rs/jacquard-api/badge.svg)](https://docs.rs/jacquard-api) 92 + - `jacquard-oauth`: atproto OAuth implementation [![Crates.io](https://img.shields.io/crates/v/jacquard-oauth.svg)](https://crates.io/crates/jacquard-oauth) [![Documentation](https://docs.rs/jacquard-oauth/badge.svg)](https://docs.rs/jacquard-oauth) 93 + - `jacquard-identity`: Identity resolution [![Crates.io](https://img.shields.io/crates/v/jacquard-identity.svg)](https://crates.io/crates/jacquard-identity) [![Documentation](https://docs.rs/jacquard-identity/badge.svg)](https://docs.rs/jacquard-identity) 94 + - `jacquard-lexicon`: Lexicon parsing and code generation [![Crates.io](https://img.shields.io/crates/v/jacquard-lexicon.svg)](https://crates.io/crates/jacquard-lexicon) [![Documentation](https://docs.rs/jacquard-lexicon/badge.svg)](https://docs.rs/jacquard-lexicon) 95 + - `jacquard-derive`: Derive macros for lexicon types [![Crates.io](https://img.shields.io/crates/v/jacquard-derive.svg)](https://crates.io/crates/jacquard-derive) [![Documentation](https://docs.rs/jacquard-derive/badge.svg)](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](https://img.shields.io/crates/l/jacquard.svg)](./LICENSE)
+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
··· 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
··· 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
··· 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
··· 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);