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
···
A suite of Rust crates for the AT Protocol.
-
[![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)
+
## Goals and Features
-
## Goals
-
-
- Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris)
+
- Validated, spec-compliant, easy to work with, and performant baseline types
- Batteries-included, but easily replaceable batteries.
-
- Easy to extend with custom lexicons
+
- Easy to extend with custom lexicons
+
- Straightforward OAuth
+
- stateless options (or options where you handle the state) for rolling your own
+
- all the building blocks of the convenient abstractions are available
- lexicon Value type for working with unknown atproto data (dag-cbor or json)
- order of magnitude less boilerplate than some existing crates
-
- either the codegen produces code that's easy to work with, or there are good handwritten wrappers
-
- didDoc type with helper methods for getting handles, multikey, and PDS endpoint
- use as much or as little from the crates as you need
-
## Example
-
Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline.
+
Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline.
```rust
-
use std::sync::Arc;
+
// Note: this requires the `loopback` feature enabled (it is currently by default)
use clap::Parser;
use jacquard::CowStr;
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
-
use jacquard::client::credential_session::{CredentialSession, SessionKey};
-
use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
-
use jacquard::identity::PublicResolver as JacquardResolver;
+
use jacquard::client::{Agent, FileAuthStore};
+
use jacquard::oauth::atproto::AtprotoClientMetadata;
+
use jacquard::oauth::client::OAuthClient;
+
use jacquard::oauth::loopback::LoopbackConfig;
+
use jacquard::oauth::scopes::Scope;
+
use jacquard::types::xrpc::XrpcClient;
use miette::IntoDiagnostic;
#[derive(Parser, Debug)]
-
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
+
#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
struct Args {
-
/// Username/handle (e.g., alice.bsky.social) or DID
-
#[arg(short, long)]
-
username: CowStr<'static>,
-
/// App password
-
#[arg(short, long)]
-
password: CowStr<'static>,
+
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
+
input: CowStr<'static>,
+
+
/// Path to auth store file (will be created if missing)
+
#[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
+
store: String,
}
#[tokio::main]
async fn main() -> miette::Result<()> {
let args = Args::parse();
-
// Resolver + storage
-
let resolver = Arc::new(JacquardResolver::default());
-
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
-
let client = Arc::new(resolver.clone());
-
let session = CredentialSession::new(store, client);
+
// File-backed auth store for testing
+
let store = FileAuthStore::new(&args.store);
+
let client_data = jacquard_oauth::session::ClientData {
+
keyset: None,
+
// Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
+
// The localhost helper will ensure you have at least "atproto" and will fix urls
+
config: AtprotoClientMetadata::default_localhost()
+
};
-
// Login (resolves PDS automatically) and persist as (did, "session")
-
session
-
.login(args.username.clone(), args.password.clone(), None, None, None)
-
.await
-
.into_diagnostic()?;
-
-
// Fetch timeline
-
let timeline = session
-
.clone()
-
.send(GetTimeline::new().limit(5).build())
-
.await
-
.into_diagnostic()?
-
.into_output()
-
.into_diagnostic()?;
-
-
println!("\ntimeline ({} posts):", timeline.feed.len());
+
// Build an OAuth client
+
let oauth = OAuthClient::new(store, client_data);
+
// Authenticate with a PDS, using a loopback server to handle the callback flow
+
let session = oauth
+
.login_with_local_server(
+
args.input.clone(),
+
Default::default(),
+
LoopbackConfig::default(),
+
)
+
.await?;
+
// Wrap in Agent and fetch the timeline
+
let agent: Agent<_> = Agent::from(session);
+
let timeline = agent
+
.send(&GetTimeline::new().limit(5).build())
+
.await?
+
.into_output()?;
for (i, post) in timeline.feed.iter().enumerate() {
println!("\n{}. by {}", i + 1, post.post.author.handle);
println!(
···
Ok(())
}
+
```
+
## Component crates
+
+
Jacquard is broken up into several crates for modularity. The correct one to use is generally `jacquard` itself, as it re-exports the others.
+
- `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)
+
- `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)
+
- `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)
+
- `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)
+
- `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)
+
- `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)
+
## Development
This repo uses [Flakes](https://nixos.asia/en/flakes) from the get-go.
···
```
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.
+
+
[![License](https://img.shields.io/crates/l/jacquard.svg)](./LICENSE)
+7
crates/jacquard-oauth/src/atproto.rs
···
}
}
+
pub fn default_localhost() -> Self {
+
Self::new_localhost(
+
None,
+
Some(Scope::parse_multiple("atproto transition:generic").unwrap()),
+
)
+
}
+
pub fn new_localhost(
mut redirect_uris: Option<Vec<Url>>,
scopes: Option<Vec<Scope<'m>>>,
+176 -176
crates/jacquard-oauth/src/request.rs
···
}
}
-
#[cfg(test)]
-
mod tests {
-
use super::*;
-
use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata};
-
use bytes::Bytes;
-
use http::{Response as HttpResponse, StatusCode};
-
use jacquard_common::http_client::HttpClient;
-
use jacquard_identity::resolver::IdentityResolver;
-
use std::sync::Arc;
-
use tokio::sync::Mutex;
-
-
#[derive(Clone, Default)]
-
struct MockClient {
-
resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
-
}
-
-
impl HttpClient for MockClient {
-
type Error = std::convert::Infallible;
-
fn send_http(
-
&self,
-
_request: http::Request<Vec<u8>>,
-
) -> impl core::future::Future<
-
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
-
> + Send {
-
let resp = self.resp.clone();
-
async move { Ok(resp.lock().await.take().unwrap()) }
-
}
-
}
-
-
// IdentityResolver methods won't be called in these tests; provide stubs.
-
#[async_trait::async_trait]
-
impl IdentityResolver for MockClient {
-
fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
-
use std::sync::LazyLock;
-
static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> =
-
LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default());
-
&OPTS
-
}
-
async fn resolve_handle(
-
&self,
-
_handle: &jacquard_common::types::string::Handle<'_>,
-
) -> std::result::Result<
-
jacquard_common::types::string::Did<'static>,
-
jacquard_identity::resolver::IdentityError,
-
> {
-
Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap())
-
}
-
async fn resolve_did_doc(
-
&self,
-
_did: &jacquard_common::types::string::Did<'_>,
-
) -> std::result::Result<
-
jacquard_identity::resolver::DidDocResponse,
-
jacquard_identity::resolver::IdentityError,
-
> {
-
let doc = serde_json::json!({
-
"id": "did:plc:alice",
-
"service": [{
-
"id": "#pds",
-
"type": "AtprotoPersonalDataServer",
-
"serviceEndpoint": "https://pds"
-
}]
-
});
-
let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
-
Ok(jacquard_identity::resolver::DidDocResponse {
-
buffer: buf,
-
status: StatusCode::OK,
-
requested: None,
-
})
-
}
-
}
-
-
// Allow using DPoP helpers on MockClient
-
impl crate::dpop::DpopExt for MockClient {}
-
impl crate::resolver::OAuthResolver for MockClient {}
-
-
fn base_metadata() -> OAuthMetadata {
-
let mut server = OAuthAuthorizationServerMetadata::default();
-
server.issuer = CowStr::from("https://issuer");
-
server.authorization_endpoint = CowStr::from("https://issuer/authorize");
-
server.token_endpoint = CowStr::from("https://issuer/token");
-
OAuthMetadata {
-
server_metadata: server,
-
client_metadata: OAuthClientMetadata {
-
client_id: url::Url::parse("https://client").unwrap(),
-
client_uri: None,
-
redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()],
-
scope: Some(CowStr::from("atproto")),
-
grant_types: None,
-
token_endpoint_auth_method: Some(CowStr::from("none")),
-
dpop_bound_access_tokens: None,
-
jwks_uri: None,
-
jwks: None,
-
token_endpoint_auth_signing_alg: None,
-
},
-
keyset: None,
-
}
-
}
-
-
#[tokio::test]
-
async fn par_missing_endpoint() {
-
let mut meta = base_metadata();
-
meta.server_metadata.require_pushed_authorization_requests = Some(true);
-
meta.server_metadata.pushed_authorization_request_endpoint = None;
-
// require_pushed_authorization_requests is true and no endpoint
-
let err = super::par(&MockClient::default(), None, None, &meta)
-
.await
-
.unwrap_err();
-
match err {
-
RequestError::NoEndpoint(name) => {
-
assert_eq!(name.as_ref(), "pushed_authorization_request");
-
}
-
other => panic!("unexpected: {other:?}"),
-
}
-
}
-
-
#[tokio::test]
-
async fn refresh_no_refresh_token() {
-
let client = MockClient::default();
-
let meta = base_metadata();
-
let session = ClientSessionData {
-
account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
-
session_id: CowStr::from("state"),
-
host_url: url::Url::parse("https://pds").unwrap(),
-
authserver_url: url::Url::parse("https://issuer").unwrap(),
-
authserver_token_endpoint: CowStr::from("https://issuer/token"),
-
authserver_revocation_endpoint: None,
-
scopes: vec![],
-
dpop_data: DpopClientData {
-
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
-
dpop_authserver_nonce: CowStr::from(""),
-
dpop_host_nonce: CowStr::from(""),
-
},
-
token_set: crate::types::TokenSet {
-
iss: CowStr::from("https://issuer"),
-
sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
-
aud: CowStr::from("https://pds"),
-
scope: None,
-
refresh_token: None,
-
access_token: CowStr::from("abc"),
-
token_type: crate::types::OAuthTokenType::DPoP,
-
expires_at: None,
-
},
-
};
-
let err = super::refresh(&client, session, &meta).await.unwrap_err();
-
matches!(err, RequestError::NoRefreshToken);
-
}
-
-
#[tokio::test]
-
async fn exchange_code_missing_sub() {
-
let client = MockClient::default();
-
// set mock HTTP response body: token response without `sub`
-
*client.resp.lock().await = Some(
-
HttpResponse::builder()
-
.status(StatusCode::OK)
-
.body(
-
serde_json::to_vec(&serde_json::json!({
-
"access_token":"tok",
-
"token_type":"DPoP",
-
"expires_in": 3600
-
}))
-
.unwrap(),
-
)
-
.unwrap(),
-
);
-
let meta = base_metadata();
-
let mut dpop = DpopReqData {
-
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
-
dpop_authserver_nonce: None,
-
};
-
let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta)
-
.await
-
.unwrap_err();
-
matches!(err, RequestError::TokenVerification);
-
}
-
}
-
#[derive(Debug, Serialize)]
pub struct RequestPayload<'a, T>
where
···
Err(RequestError::UnsupportedAuthMethod)
}
+
+
#[cfg(test)]
+
mod tests {
+
use super::*;
+
use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata};
+
use bytes::Bytes;
+
use http::{Response as HttpResponse, StatusCode};
+
use jacquard_common::http_client::HttpClient;
+
use jacquard_identity::resolver::IdentityResolver;
+
use std::sync::Arc;
+
use tokio::sync::Mutex;
+
+
#[derive(Clone, Default)]
+
struct MockClient {
+
resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>,
+
}
+
+
impl HttpClient for MockClient {
+
type Error = std::convert::Infallible;
+
fn send_http(
+
&self,
+
_request: http::Request<Vec<u8>>,
+
) -> impl core::future::Future<
+
Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>,
+
> + Send {
+
let resp = self.resp.clone();
+
async move { Ok(resp.lock().await.take().unwrap()) }
+
}
+
}
+
+
// IdentityResolver methods won't be called in these tests; provide stubs.
+
#[async_trait::async_trait]
+
impl IdentityResolver for MockClient {
+
fn options(&self) -> &jacquard_identity::resolver::ResolverOptions {
+
use std::sync::LazyLock;
+
static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> =
+
LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default());
+
&OPTS
+
}
+
async fn resolve_handle(
+
&self,
+
_handle: &jacquard_common::types::string::Handle<'_>,
+
) -> std::result::Result<
+
jacquard_common::types::string::Did<'static>,
+
jacquard_identity::resolver::IdentityError,
+
> {
+
Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap())
+
}
+
async fn resolve_did_doc(
+
&self,
+
_did: &jacquard_common::types::string::Did<'_>,
+
) -> std::result::Result<
+
jacquard_identity::resolver::DidDocResponse,
+
jacquard_identity::resolver::IdentityError,
+
> {
+
let doc = serde_json::json!({
+
"id": "did:plc:alice",
+
"service": [{
+
"id": "#pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://pds"
+
}]
+
});
+
let buf = Bytes::from(serde_json::to_vec(&doc).unwrap());
+
Ok(jacquard_identity::resolver::DidDocResponse {
+
buffer: buf,
+
status: StatusCode::OK,
+
requested: None,
+
})
+
}
+
}
+
+
// Allow using DPoP helpers on MockClient
+
impl crate::dpop::DpopExt for MockClient {}
+
impl crate::resolver::OAuthResolver for MockClient {}
+
+
fn base_metadata() -> OAuthMetadata {
+
let mut server = OAuthAuthorizationServerMetadata::default();
+
server.issuer = CowStr::from("https://issuer");
+
server.authorization_endpoint = CowStr::from("https://issuer/authorize");
+
server.token_endpoint = CowStr::from("https://issuer/token");
+
OAuthMetadata {
+
server_metadata: server,
+
client_metadata: OAuthClientMetadata {
+
client_id: url::Url::parse("https://client").unwrap(),
+
client_uri: None,
+
redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()],
+
scope: Some(CowStr::from("atproto")),
+
grant_types: None,
+
token_endpoint_auth_method: Some(CowStr::from("none")),
+
dpop_bound_access_tokens: None,
+
jwks_uri: None,
+
jwks: None,
+
token_endpoint_auth_signing_alg: None,
+
},
+
keyset: None,
+
}
+
}
+
+
#[tokio::test]
+
async fn par_missing_endpoint() {
+
let mut meta = base_metadata();
+
meta.server_metadata.require_pushed_authorization_requests = Some(true);
+
meta.server_metadata.pushed_authorization_request_endpoint = None;
+
// require_pushed_authorization_requests is true and no endpoint
+
let err = super::par(&MockClient::default(), None, None, &meta)
+
.await
+
.unwrap_err();
+
match err {
+
RequestError::NoEndpoint(name) => {
+
assert_eq!(name.as_ref(), "pushed_authorization_request");
+
}
+
other => panic!("unexpected: {other:?}"),
+
}
+
}
+
+
#[tokio::test]
+
async fn refresh_no_refresh_token() {
+
let client = MockClient::default();
+
let meta = base_metadata();
+
let session = ClientSessionData {
+
account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
+
session_id: CowStr::from("state"),
+
host_url: url::Url::parse("https://pds").unwrap(),
+
authserver_url: url::Url::parse("https://issuer").unwrap(),
+
authserver_token_endpoint: CowStr::from("https://issuer/token"),
+
authserver_revocation_endpoint: None,
+
scopes: vec![],
+
dpop_data: DpopClientData {
+
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
+
dpop_authserver_nonce: CowStr::from(""),
+
dpop_host_nonce: CowStr::from(""),
+
},
+
token_set: crate::types::TokenSet {
+
iss: CowStr::from("https://issuer"),
+
sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(),
+
aud: CowStr::from("https://pds"),
+
scope: None,
+
refresh_token: None,
+
access_token: CowStr::from("abc"),
+
token_type: crate::types::OAuthTokenType::DPoP,
+
expires_at: None,
+
},
+
};
+
let err = super::refresh(&client, session, &meta).await.unwrap_err();
+
matches!(err, RequestError::NoRefreshToken);
+
}
+
+
#[tokio::test]
+
async fn exchange_code_missing_sub() {
+
let client = MockClient::default();
+
// set mock HTTP response body: token response without `sub`
+
*client.resp.lock().await = Some(
+
HttpResponse::builder()
+
.status(StatusCode::OK)
+
.body(
+
serde_json::to_vec(&serde_json::json!({
+
"access_token":"tok",
+
"token_type":"DPoP",
+
"expires_in": 3600
+
}))
+
.unwrap(),
+
)
+
.unwrap(),
+
);
+
let meta = base_metadata();
+
let mut dpop = DpopReqData {
+
dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(),
+
dpop_authserver_nonce: None,
+
};
+
let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta)
+
.await
+
.unwrap_err();
+
matches!(err, RequestError::TokenVerification);
+
}
+
}
+2 -2
crates/jacquard-oauth/src/scopes.rs
···
-
//! AT Protocol OAuth scopes module
-
//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
+
//! AT Protocol OAuth scopes
+
//! Derived from <https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs>
//!
//! This module provides comprehensive support for AT Protocol OAuth scopes,
//! including parsing, serialization, normalization, and permission checking.
+58 -52
crates/jacquard/src/lib.rs
···
//! A suite of Rust crates for the AT Protocol.
//!
//!
-
//! ## Goals
+
//! ## Goals and Features
//!
-
//! - Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris)
+
//! - Validated, spec-compliant, easy to work with, and performant baseline types
//! - Batteries-included, but easily replaceable batteries.
//! - Easy to extend with custom lexicons
+
//! - Straightforward OAuth
+
//! - stateless options (or options where you handle the state) for rolling your own
+
//! - all the building blocks of the convenient abstractions are available
//! - lexicon Value type for working with unknown atproto data (dag-cbor or json)
//! - order of magnitude less boilerplate than some existing crates
-
//! - either the codegen produces code that's easy to work with, or there are good handwritten wrappers
-
//! - didDoc type with helper methods for getting handles, multikey, and PDS endpoint
//! - use as much or as little from the crates as you need
+
//!
//!
//!
//! ## Example
//!
-
//! Dead simple API client: login with an app password, then fetch the latest 5 posts.
+
//! Dead simple API client: login with OAuth, then fetch the latest 5 posts.
//!
//! ```no_run
//! # use clap::Parser;
//! # use jacquard::CowStr;
-
//! use std::sync::Arc;
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
-
//! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
-
//! use jacquard::identity::PublicResolver as JacquardResolver;
+
//! use jacquard::client::{Agent, FileAuthStore};
+
//! use jacquard::oauth::atproto::AtprotoClientMetadata;
+
//! use jacquard::oauth::client::OAuthClient;
//! use jacquard::types::xrpc::XrpcClient;
+
//! # #[cfg(feature = "loopback")]
+
//! use jacquard::oauth::loopback::LoopbackConfig;
//! # use miette::IntoDiagnostic;
//!
//! # #[derive(Parser, Debug)]
-
//! # #[command(author, version, about = "Jacquard - AT Protocol client demo")]
+
//! # #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")]
//! # struct Args {
-
//! # /// Username/handle (e.g., alice.bsky.social) or DID
-
//! # #[arg(short, long)]
-
//! # username: CowStr<'static>,
+
//! # /// Handle (e.g., alice.bsky.social), DID, or PDS URL
+
//! # input: CowStr<'static>,
//! #
-
//! # /// App password
-
//! # #[arg(short, long)]
-
//! # password: CowStr<'static>,
+
//! # /// Path to auth store file (will be created if missing)
+
//! # #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
+
//! # store: String,
//! # }
-
//!
+
//! #
//! #[tokio::main]
//! async fn main() -> miette::Result<()> {
//! let args = Args::parse();
-
//! // Resolver + storage
-
//! let resolver = Arc::new(JacquardResolver::default());
-
//! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
-
//! let client = Arc::new(resolver.clone());
-
//! // Create session object with implicit public appview endpoint until login/restore
-
//! let session = CredentialSession::new(store, client);
-
//! // Log in (resolves PDS automatically) and persist as (did, "session")
-
//! session
-
//! .login(args.username.clone(), args.password.clone(), None, None, None)
-
//! .await
-
//! .into_diagnostic()?;
-
//! // Fetch timeline
-
//! let timeline = session
+
//!
+
//! // File-backed auth store shared by OAuthClient and session registry
+
//! let store = FileAuthStore::new(&args.store);
+
//! let client_data = jacquard_oauth::session::ClientData {
+
//! keyset: None,
+
//! // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
+
//! // The localhost helper will ensure you have at least "atproto" and will fix urls
+
//! config: AtprotoClientMetadata::default_localhost(),
+
//! };
+
//!
+
//! // Build an OAuth client (this is reusable, and can create multiple sessions)
+
//! let oauth = OAuthClient::new(store, client_data);
+
//! // Authenticate with a PDS, using a loopback server to handle the callback flow
+
//! # #[cfg(feature = "loopback")]
+
//! let session = oauth
+
//! .login_with_local_server(
+
//! args.input.clone(),
+
//! Default::default(),
+
//! LoopbackConfig::default(),
+
//! )
+
//! .await?;
+
//! # #[cfg(not(feature = "loopback"))]
+
//! # compile_error!("loopback feature must be enabled to run this example");
+
//! // Wrap in Agent and fetch the timeline
+
//! let agent: Agent<_> = Agent::from(session);
+
//! let timeline = agent
//! .send(&GetTimeline::new().limit(5).build())
-
//! .await
-
//! .into_diagnostic()?
-
//! .into_output()
-
//! .into_diagnostic()?;
-
//! println!("timeline ({} posts):", timeline.feed.len());
+
//! .await?
+
//! .into_output()?;
//! for (i, post) in timeline.feed.iter().enumerate() {
-
//! println!("{}. by {}", i + 1, post.post.author.handle);
+
//! println!("\n{}. by {}", i + 1, post.post.author.handle);
+
//! println!(
+
//! " {}",
+
//! serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
+
//! );
//! }
//! Ok(())
-
//! }
+
//!}
//! ```
//!
//! ## Client options:
···
//! }
//! ```
//! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and
-
//! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the
+
//! `T: IdentityResolver + HttpClient`. It auto-attaches bearer authorization, refreshes on expiry, and updates the
//! base endpoint to the user's PDS on login/restore.
+
//! - Stateful client (OAuth): `OAuthClient<S, T>` and `OAuthSession<S, T>` where `S: ClientAuthStore` and
+
//! `T: OAuthResolver + HttpClient`. The client is used to authenticate, returning a session which handles authentication and token refresh internally.
+
//! - `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.
//!
//! Per-request overrides (stateless)
//! ```no_run
···
//! Ok(())
//! }
//! ```
-
//!
-
//! Token storage:
-
//! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests.
-
//! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions
-
//! and OAuth `ClientAuthStore` (unified on-disk map).
-
//! ```no_run
-
//! use std::sync::Arc;
-
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
-
//! use jacquard::client::{AtpSession, FileAuthStore};
-
//! use jacquard::identity::PublicResolver;
-
//! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json"));
-
//! let client = Arc::new(PublicResolver::default());
-
//! let session = CredentialSession::new(store, client);
-
//! ```
-
//!
#![warn(missing_docs)]
···
pub use jacquard_derive::*;
pub use jacquard_identity as identity;
+
pub use jacquard_oauth as oauth;
+35 -16
crates/jacquard/src/main.rs
···
use clap::Parser;
use jacquard::CowStr;
+
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
use jacquard::client::{Agent, FileAuthStore};
+
use jacquard::oauth::atproto::AtprotoClientMetadata;
+
use jacquard::oauth::client::OAuthClient;
+
#[cfg(feature = "loopback")]
+
use jacquard::oauth::loopback::LoopbackConfig;
use jacquard::types::xrpc::XrpcClient;
-
use jacquard_api::app_bsky::feed::get_timeline::GetTimeline;
-
use jacquard_oauth::atproto::AtprotoClientMetadata;
-
use jacquard_oauth::client::OAuthClient;
-
#[cfg(feature = "loopback")]
-
use jacquard_oauth::loopback::LoopbackConfig;
-
use jacquard_oauth::scopes::Scope;
+
#[cfg(not(feature = "loopback"))]
+
use jacquard_oauth::types::AuthorizeOptions;
use miette::IntoDiagnostic;
#[derive(Parser, Debug)]
···
async fn main() -> miette::Result<()> {
let args = Args::parse();
-
// File-backed auth store shared by OAuthClient and session registry
+
// File-backed auth store for testing
let store = FileAuthStore::new(&args.store);
// Minimal localhost client metadata (redirect_uris get set by loopback helper)
let client_data = jacquard_oauth::session::ClientData {
keyset: None,
-
// scopes: include atproto; redirect_uris will be populated by the loopback helper
-
config: AtprotoClientMetadata::new_localhost(None, Some(vec![Scope::Atproto])),
+
// Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
+
// The localhost helper will ensure you have at least "atproto" and will fix urls
+
config: AtprotoClientMetadata::default_localhost(),
};
-
// Build an OAuth client and run loopback flow
+
// Build an OAuth client
let oauth = OAuthClient::new(store, client_data);
#[cfg(feature = "loopback")]
+
// Authenticate with a PDS, using a loopback server to handle the callback flow
let session = oauth
.login_with_local_server(
args.input.clone(),
Default::default(),
LoopbackConfig::default(),
)
-
.await
-
.into_diagnostic()?;
+
.await?;
#[cfg(not(feature = "loopback"))]
-
compile_error!("loopback feature must be enabled to run this example");
+
let session = {
+
use std::io::{BufRead, Write, stdin, stdout};
+
+
let auth_url = oauth
+
.start_auth(args.input, AuthorizeOptions::default())
+
.await?;
+
+
println!("To authenticate with your PDS, visit:\n{}\n", auth_url);
+
print!("\nPaste the callback url here:");
+
stdout().lock().flush().into_diagnostic()?;
+
let mut url = String::new();
+
stdin().lock().read_line(&mut url).into_diagnostic()?;
+
+
let uri = url.trim().parse::<http::Uri>().into_diagnostic()?;
+
let params =
+
serde_html_form::from_str(uri.query().ok_or(miette::miette!("invalid callback url"))?)
+
.into_diagnostic()?;
+
oauth.callback(params).await?
+
};
-
// Wrap in Agent and call a simple resource endpoint
+
// Wrap in Agent and fetch the timeline
let agent: Agent<_> = Agent::from(session);
let timeline = agent
.send(&GetTimeline::new().limit(5).build())
-
.await
-
.into_diagnostic()?
+
.await?
.into_output()?;
for (i, post) in timeline.feed.iter().enumerate() {
println!("\n{}. by {}", i + 1, post.post.author.handle);