···2233A suite of Rust crates for the AT Protocol.
4455-[](https://crates.io/crates/jacquard) [](https://docs.rs/jacquard) [](./LICENSE)
55+## Goals and Features
6677-## Goals
88-99-- Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris)
77+- Validated, spec-compliant, easy to work with, and performant baseline types
108- Batteries-included, but easily replaceable batteries.
1111- - Easy to extend with custom lexicons
99+ - Easy to extend with custom lexicons
1010+ - Straightforward OAuth
1111+ - stateless options (or options where you handle the state) for rolling your own
1212+ - all the building blocks of the convenient abstractions are available
1213- lexicon Value type for working with unknown atproto data (dag-cbor or json)
1314- order of magnitude less boilerplate than some existing crates
1414- - either the codegen produces code that's easy to work with, or there are good handwritten wrappers
1515-- didDoc type with helper methods for getting handles, multikey, and PDS endpoint
1615- use as much or as little from the crates as you need
17161818-1917## Example
20182121-Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline.
1919+Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline.
22202321```rust
2424-use std::sync::Arc;
2222+// Note: this requires the `loopback` feature enabled (it is currently by default)
2523use clap::Parser;
2624use jacquard::CowStr;
2725use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
2828-use jacquard::client::credential_session::{CredentialSession, SessionKey};
2929-use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
3030-use jacquard::identity::PublicResolver as JacquardResolver;
2626+use jacquard::client::{Agent, FileAuthStore};
2727+use jacquard::oauth::atproto::AtprotoClientMetadata;
2828+use jacquard::oauth::client::OAuthClient;
2929+use jacquard::oauth::loopback::LoopbackConfig;
3030+use jacquard::oauth::scopes::Scope;
3131+use jacquard::types::xrpc::XrpcClient;
3132use miette::IntoDiagnostic;
32333334#[derive(Parser, Debug)]
3434-#[command(author, version, about = "Jacquard - AT Protocol client demo")]
3535+#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
3536struct Args {
3636- /// Username/handle (e.g., alice.bsky.social) or DID
3737- #[arg(short, long)]
3838- username: CowStr<'static>,
3939- /// App password
4040- #[arg(short, long)]
4141- password: CowStr<'static>,
3737+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
3838+ input: CowStr<'static>,
3939+4040+ /// Path to auth store file (will be created if missing)
4141+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
4242+ store: String,
4243}
43444445#[tokio::main]
4546async fn main() -> miette::Result<()> {
4647 let args = Args::parse();
47484848- // Resolver + storage
4949- let resolver = Arc::new(JacquardResolver::default());
5050- let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
5151- let client = Arc::new(resolver.clone());
5252- let session = CredentialSession::new(store, client);
4949+ // File-backed auth store for testing
5050+ let store = FileAuthStore::new(&args.store);
5151+ let client_data = jacquard_oauth::session::ClientData {
5252+ keyset: None,
5353+ // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
5454+ // The localhost helper will ensure you have at least "atproto" and will fix urls
5555+ config: AtprotoClientMetadata::default_localhost()
5656+ };
53575454- // Login (resolves PDS automatically) and persist as (did, "session")
5555- session
5656- .login(args.username.clone(), args.password.clone(), None, None, None)
5757- .await
5858- .into_diagnostic()?;
5959-6060- // Fetch timeline
6161- let timeline = session
6262- .clone()
6363- .send(GetTimeline::new().limit(5).build())
6464- .await
6565- .into_diagnostic()?
6666- .into_output()
6767- .into_diagnostic()?;
6868-6969- println!("\ntimeline ({} posts):", timeline.feed.len());
5858+ // Build an OAuth client
5959+ let oauth = OAuthClient::new(store, client_data);
6060+ // Authenticate with a PDS, using a loopback server to handle the callback flow
6161+ let session = oauth
6262+ .login_with_local_server(
6363+ args.input.clone(),
6464+ Default::default(),
6565+ LoopbackConfig::default(),
6666+ )
6767+ .await?;
6868+ // Wrap in Agent and fetch the timeline
6969+ let agent: Agent<_> = Agent::from(session);
7070+ let timeline = agent
7171+ .send(&GetTimeline::new().limit(5).build())
7272+ .await?
7373+ .into_output()?;
7074 for (i, post) in timeline.feed.iter().enumerate() {
7175 println!("\n{}. by {}", i + 1, post.post.author.handle);
7276 println!(
···77817882 Ok(())
7983}
8484+8085```
81868787+## Component crates
8888+8989+Jacquard is broken up into several crates for modularity. The correct one to use is generally `jacquard` itself, as it re-exports the others.
9090+- `jacquard`: Main crate [](https://crates.io/crates/jacquard) [](https://docs.rs/jacquard)
9191+- `jacquard-api`: Autogenerated API bindings [](https://crates.io/crates/jacquard-api) [](https://docs.rs/jacquard-api)
9292+- `jacquard-oauth`: atproto OAuth implementation [](https://crates.io/crates/jacquard-oauth) [](https://docs.rs/jacquard-oauth)
9393+- `jacquard-identity`: Identity resolution [](https://crates.io/crates/jacquard-identity) [](https://docs.rs/jacquard-identity)
9494+- `jacquard-lexicon`: Lexicon parsing and code generation [](https://crates.io/crates/jacquard-lexicon) [](https://docs.rs/jacquard-lexicon)
9595+- `jacquard-derive`: Derive macros for lexicon types [](https://crates.io/crates/jacquard-derive) [](https://docs.rs/jacquard-derive)
9696+8297## Development
83988499This repo uses [Flakes](https://nixos.asia/en/flakes) from the get-go.
···95110```
9611197112There'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.
113113+114114+[](./LICENSE)
···11-//! AT Protocol OAuth scopes module
22-//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
11+//! AT Protocol OAuth scopes
22+//! Derived from <https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs>
33//!
44//! This module provides comprehensive support for AT Protocol OAuth scopes,
55//! including parsing, serialization, normalization, and permission checking.
+58-52
crates/jacquard/src/lib.rs
···33//! A suite of Rust crates for the AT Protocol.
44//!
55//!
66-//! ## Goals
66+//! ## Goals and Features
77//!
88-//! - Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris)
88+//! - Validated, spec-compliant, easy to work with, and performant baseline types
99//! - Batteries-included, but easily replaceable batteries.
1010//! - Easy to extend with custom lexicons
1111+//! - Straightforward OAuth
1212+//! - stateless options (or options where you handle the state) for rolling your own
1313+//! - all the building blocks of the convenient abstractions are available
1114//! - lexicon Value type for working with unknown atproto data (dag-cbor or json)
1215//! - order of magnitude less boilerplate than some existing crates
1313-//! - either the codegen produces code that's easy to work with, or there are good handwritten wrappers
1414-//! - didDoc type with helper methods for getting handles, multikey, and PDS endpoint
1516//! - use as much or as little from the crates as you need
1717+//!
1618//!
1719//!
1820//! ## Example
1921//!
2020-//! Dead simple API client: login with an app password, then fetch the latest 5 posts.
2222+//! Dead simple API client: login with OAuth, then fetch the latest 5 posts.
2123//!
2224//! ```no_run
2325//! # use clap::Parser;
2426//! # use jacquard::CowStr;
2525-//! use std::sync::Arc;
2627//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
2728//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
2828-//! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
2929-//! use jacquard::identity::PublicResolver as JacquardResolver;
2929+//! use jacquard::client::{Agent, FileAuthStore};
3030+//! use jacquard::oauth::atproto::AtprotoClientMetadata;
3131+//! use jacquard::oauth::client::OAuthClient;
3032//! use jacquard::types::xrpc::XrpcClient;
3333+//! # #[cfg(feature = "loopback")]
3434+//! use jacquard::oauth::loopback::LoopbackConfig;
3135//! # use miette::IntoDiagnostic;
3236//!
3337//! # #[derive(Parser, Debug)]
3434-//! # #[command(author, version, about = "Jacquard - AT Protocol client demo")]
3838+//! # #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
3539//! # struct Args {
3636-//! # /// Username/handle (e.g., alice.bsky.social) or DID
3737-//! # #[arg(short, long)]
3838-//! # username: CowStr<'static>,
4040+//! # /// Handle (e.g., alice.bsky.social), DID, or PDS URL
4141+//! # input: CowStr<'static>,
3942//! #
4040-//! # /// App password
4141-//! # #[arg(short, long)]
4242-//! # password: CowStr<'static>,
4343+//! # /// Path to auth store file (will be created if missing)
4444+//! # #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
4545+//! # store: String,
4346//! # }
4444-//!
4747+//! #
4548//! #[tokio::main]
4649//! async fn main() -> miette::Result<()> {
4750//! let args = Args::parse();
4848-//! // Resolver + storage
4949-//! let resolver = Arc::new(JacquardResolver::default());
5050-//! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
5151-//! let client = Arc::new(resolver.clone());
5252-//! // Create session object with implicit public appview endpoint until login/restore
5353-//! let session = CredentialSession::new(store, client);
5454-//! // Log in (resolves PDS automatically) and persist as (did, "session")
5555-//! session
5656-//! .login(args.username.clone(), args.password.clone(), None, None, None)
5757-//! .await
5858-//! .into_diagnostic()?;
5959-//! // Fetch timeline
6060-//! let timeline = session
5151+//!
5252+//! // File-backed auth store shared by OAuthClient and session registry
5353+//! let store = FileAuthStore::new(&args.store);
5454+//! let client_data = jacquard_oauth::session::ClientData {
5555+//! keyset: None,
5656+//! // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
5757+//! // The localhost helper will ensure you have at least "atproto" and will fix urls
5858+//! config: AtprotoClientMetadata::default_localhost(),
5959+//! };
6060+//!
6161+//! // Build an OAuth client (this is reusable, and can create multiple sessions)
6262+//! let oauth = OAuthClient::new(store, client_data);
6363+//! // Authenticate with a PDS, using a loopback server to handle the callback flow
6464+//! # #[cfg(feature = "loopback")]
6565+//! let session = oauth
6666+//! .login_with_local_server(
6767+//! args.input.clone(),
6868+//! Default::default(),
6969+//! LoopbackConfig::default(),
7070+//! )
7171+//! .await?;
7272+//! # #[cfg(not(feature = "loopback"))]
7373+//! # compile_error!("loopback feature must be enabled to run this example");
7474+//! // Wrap in Agent and fetch the timeline
7575+//! let agent: Agent<_> = Agent::from(session);
7676+//! let timeline = agent
6177//! .send(&GetTimeline::new().limit(5).build())
6262-//! .await
6363-//! .into_diagnostic()?
6464-//! .into_output()
6565-//! .into_diagnostic()?;
6666-//! println!("timeline ({} posts):", timeline.feed.len());
7878+//! .await?
7979+//! .into_output()?;
6780//! for (i, post) in timeline.feed.iter().enumerate() {
6868-//! println!("{}. by {}", i + 1, post.post.author.handle);
8181+//! println!("\n{}. by {}", i + 1, post.post.author.handle);
8282+//! println!(
8383+//! " {}",
8484+//! serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
8585+//! );
6986//! }
7087//! Ok(())
7171-//! }
8888+//!}
7289//! ```
7390//!
7491//! ## Client options:
···102119//! }
103120//! ```
104121//! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and
105105-//! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the
122122+//! `T: IdentityResolver + HttpClient`. It auto-attaches bearer authorization, refreshes on expiry, and updates the
106123//! base endpoint to the user's PDS on login/restore.
124124+//! - Stateful client (OAuth): `OAuthClient<S, T>` and `OAuthSession<S, T>` where `S: ClientAuthStore` and
125125+//! `T: OAuthResolver + HttpClient`. The client is used to authenticate, returning a session which handles authentication and token refresh internally.
126126+//! - `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.
107127//!
108128//! Per-request overrides (stateless)
109129//! ```no_run
···135155//! Ok(())
136156//! }
137157//! ```
138138-//!
139139-//! Token storage:
140140-//! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests.
141141-//! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions
142142-//! and OAuth `ClientAuthStore` (unified on-disk map).
143143-//! ```no_run
144144-//! use std::sync::Arc;
145145-//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
146146-//! use jacquard::client::{AtpSession, FileAuthStore};
147147-//! use jacquard::identity::PublicResolver;
148148-//! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json"));
149149-//! let client = Arc::new(PublicResolver::default());
150150-//! let session = CredentialSession::new(store, client);
151151-//! ```
152152-//!
153158154159#![warn(missing_docs)]
155160···167172pub use jacquard_derive::*;
168173169174pub use jacquard_identity as identity;
175175+pub use jacquard_oauth as oauth;
+35-16
crates/jacquard/src/main.rs
···11use clap::Parser;
22use jacquard::CowStr;
33+use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
34use jacquard::client::{Agent, FileAuthStore};
55+use jacquard::oauth::atproto::AtprotoClientMetadata;
66+use jacquard::oauth::client::OAuthClient;
77+#[cfg(feature = "loopback")]
88+use jacquard::oauth::loopback::LoopbackConfig;
49use jacquard::types::xrpc::XrpcClient;
55-use jacquard_api::app_bsky::feed::get_timeline::GetTimeline;
66-use jacquard_oauth::atproto::AtprotoClientMetadata;
77-use jacquard_oauth::client::OAuthClient;
88-#[cfg(feature = "loopback")]
99-use jacquard_oauth::loopback::LoopbackConfig;
1010-use jacquard_oauth::scopes::Scope;
1010+#[cfg(not(feature = "loopback"))]
1111+use jacquard_oauth::types::AuthorizeOptions;
1112use miette::IntoDiagnostic;
12131314#[derive(Parser, Debug)]
···2526async fn main() -> miette::Result<()> {
2627 let args = Args::parse();
27282828- // File-backed auth store shared by OAuthClient and session registry
2929+ // File-backed auth store for testing
2930 let store = FileAuthStore::new(&args.store);
30313132 // Minimal localhost client metadata (redirect_uris get set by loopback helper)
3233 let client_data = jacquard_oauth::session::ClientData {
3334 keyset: None,
3434- // scopes: include atproto; redirect_uris will be populated by the loopback helper
3535- config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
3535+ // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
3636+ // The localhost helper will ensure you have at least "atproto" and will fix urls
3737+ config: AtprotoClientMetadata::default_localhost(),
3638 };
37393838- // Build an OAuth client and run loopback flow
4040+ // Build an OAuth client
3941 let oauth = OAuthClient::new(store, client_data);
40424143 #[cfg(feature = "loopback")]
4444+ // Authenticate with a PDS, using a loopback server to handle the callback flow
4245 let session = oauth
4346 .login_with_local_server(
4447 args.input.clone(),
4548 Default::default(),
4649 LoopbackConfig::default(),
4750 )
4848- .await
4949- .into_diagnostic()?;
5151+ .await?;
50525153 #[cfg(not(feature = "loopback"))]
5252- compile_error!("loopback feature must be enabled to run this example");
5454+ let session = {
5555+ use std::io::{BufRead, Write, stdin, stdout};
5656+5757+ let auth_url = oauth
5858+ .start_auth(args.input, AuthorizeOptions::default())
5959+ .await?;
6060+6161+ println!("To authenticate with your PDS, visit:\n{}\n", auth_url);
6262+ print!("\nPaste the callback url here:");
6363+ stdout().lock().flush().into_diagnostic()?;
6464+ let mut url = String::new();
6565+ stdin().lock().read_line(&mut url).into_diagnostic()?;
6666+6767+ let uri = url.trim().parse::<http::Uri>().into_diagnostic()?;
6868+ let params =
6969+ serde_html_form::from_str(uri.query().ok_or(miette::miette!("invalid callback url"))?)
7070+ .into_diagnostic()?;
7171+ oauth.callback(params).await?
7272+ };
53735454- // Wrap in Agent and call a simple resource endpoint
7474+ // Wrap in Agent and fetch the timeline
5575 let agent: Agent<_> = Agent::from(session);
5676 let timeline = agent
5777 .send(&GetTimeline::new().limit(5).build())
5858- .await
5959- .into_diagnostic()?
7878+ .await?
6079 .into_output()?;
6180 for (i, post) in timeline.feed.iter().enumerate() {
6281 println!("\n{}. by {}", i + 1, post.post.author.handle);