A better Rust ATProto crate

reworking legacy session

Orual 3a7692c4 80ccbe95

Changed files
+215 -30
crates
jacquard
jacquard-common
jacquard-identity
src
jacquard-oauth
src
+1 -1
crates/jacquard-common/src/session.rs
···
async fn access_token(&self) -> Result<AuthorizationToken, SessionStoreError>;
-
async fn refresh(&self) -> Result<(), SessionStoreError>;
+
async fn refresh(&self) -> Result<AuthorizationToken, SessionStoreError>;
}
/// Errors emitted by session stores.
+11 -11
crates/jacquard-identity/src/lib.rs
···
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
/// Default resolver implementation with configurable fallback order.
-
pub struct DefaultResolver {
+
pub struct JacquardResolver {
http: reqwest::Client,
opts: ResolverOptions,
#[cfg(feature = "dns")]
dns: Option<TokioAsyncResolver>,
}
-
impl DefaultResolver {
+
impl JacquardResolver {
/// Create a new instance of the default resolver with all options (except DNS) up front
pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
Self {
···
}
}
-
impl DefaultResolver {
+
impl JacquardResolver {
/// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
pub async fn resolve_handle_via_pds(
&self,
···
}
#[async_trait::async_trait]
-
impl IdentityResolver for DefaultResolver {
+
impl IdentityResolver for JacquardResolver {
fn options(&self) -> &ResolverOptions {
&self.opts
}
···
}
}
-
impl HttpClient for DefaultResolver {
+
impl HttpClient for JacquardResolver {
async fn send_http(
&self,
request: http::Request<Vec<u8>>,
···
},
}
-
impl DefaultResolver {
+
impl JacquardResolver {
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
/// This applies the default equality check on the document id (error with doc if mismatch).
pub async fn resolve_handle_and_doc(
···
}
/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
-
pub type PublicResolver = DefaultResolver;
+
pub type PublicResolver = JacquardResolver;
impl Default for PublicResolver {
/// Build a resolver with:
···
fn default() -> Self {
let http = reqwest::Client::new();
let opts = ResolverOptions::default();
-
let resolver = DefaultResolver::new(http, opts);
+
let resolver = JacquardResolver::new(http, opts);
#[cfg(feature = "dns")]
let resolver = resolver.with_system_dns();
resolver
···
let http = reqwest::Client::new();
let mut opts = ResolverOptions::default();
opts.plc_source = PlcSource::slingshot_default();
-
let resolver = DefaultResolver::new(http, opts);
+
let resolver = JacquardResolver::new(http, opts);
#[cfg(feature = "dns")]
let resolver = resolver.with_system_dns();
resolver
···
#[test]
fn did_web_urls() {
-
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
+
let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
assert_eq!(
r.test_did_web_url_raw("did:web:example.com"),
"https://example.com/.well-known/did.json"
···
#[test]
fn slingshot_mini_doc_url_build() {
-
let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
+
let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
assert_eq!(
+12 -14
crates/jacquard-oauth/src/client.rs
···
+
use crate::{
+
atproto::atproto_client_metadata,
+
authstore::ClientAuthStore,
+
dpop::DpopExt,
+
error::{OAuthError, Result},
+
request::{OAuthMetadata, exchange_code, par},
+
resolver::OAuthResolver,
+
scopes::Scope,
+
session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry},
+
types::{AuthorizeOptions, CallbackParams},
+
};
use jacquard_common::{
AuthorizationToken, CowStr, IntoStatic,
error::{AuthError, ClientError, TransportError, XrpcResult},
···
},
};
use jose_jwk::JwkSet;
-
use smol_str::SmolStr;
use std::sync::Arc;
use tokio::sync::RwLock;
use url::Url;
-
-
use crate::{
-
atproto::atproto_client_metadata,
-
authstore::ClientAuthStore,
-
dpop::DpopExt,
-
error::{OAuthError, Result},
-
request::{OAuthMetadata, exchange_code, par},
-
resolver::OAuthResolver,
-
scopes::Scope,
-
session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry},
-
types::{AuthorizeOptions, CallbackParams},
-
};
pub struct OAuthClient<T, S>
where
···
(data.account_did.clone(), data.session_id.clone())
}
-
pub async fn pds(&self) -> Url {
+
pub async fn endpoint(&self) -> Url {
self.data.read().await.host_url.clone()
}
+1 -3
crates/jacquard/src/client.rs
···
//! client implementation that manages session tokens.
mod at_client;
-
+
pub mod credential_session;
mod token;
pub use at_client::{AtClient, SendOverrides};
···
};
pub use token::FileAuthStore;
use url::Url;
-
-
// Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs
pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
+189
crates/jacquard/src/client/credential_session.rs
···
+
use std::sync::Arc;
+
+
use jacquard_api::com_atproto::server::refresh_session::RefreshSession;
+
use jacquard_common::{
+
AuthorizationToken, CowStr, IntoStatic,
+
error::{AuthError, ClientError, XrpcResult},
+
http_client::HttpClient,
+
session::SessionStore,
+
types::{
+
did::Did,
+
xrpc::{CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest},
+
},
+
};
+
use tokio::sync::RwLock;
+
use url::Url;
+
+
use crate::client::{AtpSession, token::StoredSession};
+
+
pub type SessionKey = (Did<'static>, CowStr<'static>);
+
+
pub struct CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession>,
+
{
+
store: Arc<S>,
+
client: Arc<T>,
+
pub options: RwLock<CallOptions<'static>>,
+
pub key: RwLock<Option<SessionKey>>,
+
pub endpoint: RwLock<Option<Url>>,
+
}
+
+
impl<S, T> CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession>,
+
{
+
pub fn new(store: Arc<S>, client: Arc<T>) -> Self {
+
Self {
+
store,
+
client,
+
options: RwLock::new(CallOptions::default()),
+
key: RwLock::new(None),
+
endpoint: RwLock::new(None),
+
}
+
}
+
}
+
+
impl<S, T> CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession>,
+
{
+
pub fn with_options(self, options: CallOptions<'_>) -> Self {
+
Self {
+
client: self.client,
+
store: self.store,
+
options: RwLock::new(options.into_static()),
+
key: self.key,
+
endpoint: self.endpoint,
+
}
+
}
+
+
pub async fn set_options(&self, options: CallOptions<'_>) {
+
*self.options.write().await = options.into_static();
+
}
+
+
pub async fn session_info(&self) -> Option<SessionKey> {
+
self.key.read().await.clone()
+
}
+
+
pub async fn endpoint(&self) -> Url {
+
self.endpoint.read().await.clone().unwrap_or(
+
Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
+
)
+
}
+
+
pub async fn set_endpoint(&self, endpoint: Url) {
+
*self.endpoint.write().await = Some(endpoint);
+
}
+
+
pub async fn access_token(&self) -> Option<AuthorizationToken<'_>> {
+
let key = self.key.read().await.clone()?;
+
let session = self.store.get(&key).await;
+
session.map(|session| AuthorizationToken::Bearer(session.access_jwt))
+
}
+
+
pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
+
let key = self.key.read().await.clone()?;
+
let session = self.store.get(&key).await;
+
session.map(|session| AuthorizationToken::Bearer(session.refresh_jwt))
+
}
+
}
+
+
impl<S, T> CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession>,
+
T: HttpClient,
+
{
+
pub async fn refresh(&self) -> Result<AuthorizationToken<'_>, ClientError> {
+
let key = self.key.read().await.clone().ok_or(ClientError::Auth(
+
jacquard_common::error::AuthError::NotAuthenticated,
+
))?;
+
let session = self.store.get(&key).await;
+
let endpoint = self.endpoint().await;
+
let mut opts = self.options.read().await.clone();
+
opts.auth = session.map(|s| AuthorizationToken::Bearer(s.refresh_jwt));
+
let response = self
+
.client
+
.xrpc(endpoint)
+
.with_options(opts)
+
.send(&RefreshSession)
+
.await?;
+
let refresh = response
+
.into_output()
+
.map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?;
+
+
let new_session: AtpSession = refresh.into();
+
let token = AuthorizationToken::Bearer(new_session.access_jwt.clone());
+
self.store
+
.set(key, new_session)
+
.await
+
.map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?;
+
+
Ok(token)
+
}
+
}
+
+
impl<S, T> HttpClient for CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
+
T: HttpClient + XrpcExt + Send + Sync + 'static,
+
{
+
type Error = T::Error;
+
+
async fn send_http(
+
&self,
+
request: http::Request<Vec<u8>>,
+
) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
+
self.client.send_http(request).await
+
}
+
}
+
+
impl<S, T> XrpcClient for CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
+
T: HttpClient + XrpcExt + Send + Sync + 'static,
+
{
+
fn base_uri(&self) -> Url {
+
self.endpoint.blocking_read().clone().unwrap_or(
+
Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
+
)
+
}
+
async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>(
+
self,
+
request: &R,
+
) -> XrpcResult<Response<R>> {
+
let base_uri = self.base_uri();
+
let auth = self.access_token().await;
+
let mut opts = self.options.read().await.clone();
+
opts.auth = auth;
+
let resp = self
+
.client
+
.xrpc(base_uri.clone())
+
.with_options(opts.clone())
+
.send(request)
+
.await;
+
+
if is_expired(&resp) {
+
let auth = self.refresh().await?;
+
opts.auth = Some(auth);
+
self.client
+
.xrpc(base_uri)
+
.with_options(opts)
+
.send(request)
+
.await
+
} else {
+
resp
+
}
+
}
+
}
+
+
fn is_expired<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool {
+
match response {
+
Err(ClientError::Auth(AuthError::TokenExpired)) => true,
+
Ok(resp) => match resp.parse() {
+
Err(XrpcError::Auth(AuthError::TokenExpired)) => true,
+
_ => false,
+
},
+
_ => false,
+
}
+
}
+1 -1
crates/jacquard/src/client/token.rs
···
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
-
enum StoredSession {
+
pub enum StoredSession {
Atp(StoredAtSession),
OAuth(OAuthSession),
OAuthState(OAuthState),