A better Rust ATProto crate

stateful xrpc trait, oauth client mostly done

Orual 80ccbe95 102d6666

Changed files
+690 -116
crates
jacquard
jacquard-common
jacquard-identity
src
jacquard-oauth
+10
crates/jacquard-common/src/lib.rs
···
/// DPoP token (proof-of-possession) for OAuth
Dpop(CowStr<'s>),
}
+
+
impl<'s> IntoStatic for AuthorizationToken<'s> {
+
type Output = AuthorizationToken<'static>;
+
fn into_static(self) -> AuthorizationToken<'static> {
+
match self {
+
AuthorizationToken::Bearer(token) => AuthorizationToken::Bearer(token.into_static()),
+
AuthorizationToken::Dpop(token) => AuthorizationToken::Dpop(token.into_static()),
+
}
+
}
+
}
+89 -5
crates/jacquard-common/src/session.rs
···
use async_trait::async_trait;
use miette::Diagnostic;
+
use serde::Serialize;
+
use serde::de::DeserializeOwned;
+
use serde_json::Value;
use std::collections::HashMap;
use std::error::Error as StdError;
+
use std::fmt::Display;
use std::hash::Hash;
+
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
+
use url::Url;
+
+
use crate::AuthorizationToken;
+
use crate::types::did::Did;
+
+
#[async_trait::async_trait]
+
pub trait Session {
+
async fn did(&self) -> Did<'_>;
+
+
async fn endpoint(&self) -> Url;
+
+
async fn access_token(&self) -> Result<AuthorizationToken, SessionStoreError>;
+
+
async fn refresh(&self) -> Result<(), SessionStoreError>;
+
}
/// Errors emitted by session stores.
#[derive(Debug, thiserror::Error, Diagnostic)]
···
async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>;
/// Delete the given session.
async fn del(&self, key: &K) -> Result<(), SessionStoreError>;
-
/// Remove all stored sessions.
-
async fn clear(&self) -> Result<(), SessionStoreError>;
}
/// In-memory session store suitable for short-lived sessions and tests.
···
self.0.write().await.remove(key);
Ok(())
}
-
async fn clear(&self) -> Result<(), SessionStoreError> {
-
self.0.write().await.clear();
-
Ok(())
+
}
+
+
/// File-backed token store using a JSON file.
+
///
+
/// NOT secure, only suitable for development.
+
///
+
/// Example
+
/// ```ignore
+
/// use jacquard::client::{AtClient, FileTokenStore};
+
/// let base = url::Url::parse("https://bsky.social").unwrap();
+
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
+
/// let client = AtClient::new(reqwest::Client::new(), base, store);
+
/// ```
+
#[derive(Clone, Debug)]
+
pub struct FileTokenStore {
+
/// Path to the JSON file.
+
pub path: PathBuf,
+
}
+
+
impl FileTokenStore {
+
/// Create a new file token store at the given path.
+
pub fn new(path: impl AsRef<Path>) -> Self {
+
Self {
+
path: path.as_ref().to_path_buf(),
+
}
+
}
+
}
+
+
#[async_trait::async_trait]
+
impl<
+
K: Eq + Hash + Display + Send + Sync + 'static,
+
T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
+
> SessionStore<K, T> for FileTokenStore
+
{
+
/// Get the current session if present.
+
async fn get(&self, key: &K) -> Option<T> {
+
let file = std::fs::read_to_string(&self.path).ok()?;
+
let store: Value = serde_json::from_str(&file).ok()?;
+
+
let session = store.get(key.to_string())?;
+
serde_json::from_value(session.clone()).ok()
+
}
+
/// Persist the given session.
+
async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
+
let file = std::fs::read_to_string(&self.path)?;
+
let mut store: Value = serde_json::from_str(&file)?;
+
let key_string = key.to_string();
+
if let Some(store) = store.as_object_mut() {
+
store.insert(key_string, serde_json::to_value(session.clone())?);
+
+
std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
+
Ok(())
+
} else {
+
Err(SessionStoreError::Other("invalid store".into()))
+
}
+
}
+
/// Delete the given session.
+
async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
+
let file = std::fs::read_to_string(&self.path)?;
+
let mut store: Value = serde_json::from_str(&file)?;
+
let key_string = key.to_string();
+
if let Some(store) = store.as_object_mut() {
+
store.remove(&key_string);
+
+
std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
+
Ok(())
+
} else {
+
Err(SessionStoreError::Other("invalid store".into()))
+
}
}
}
+33 -2
crates/jacquard-common/src/types/xrpc.rs
···
pub extra_headers: Vec<(HeaderName, HeaderValue)>,
}
+
impl IntoStatic for CallOptions<'_> {
+
type Output = CallOptions<'static>;
+
+
fn into_static(self) -> Self::Output {
+
CallOptions {
+
auth: self.auth.map(|auth| auth.into_static()),
+
atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()),
+
atproto_accept_labelers: self
+
.atproto_accept_labelers
+
.map(|labelers| labelers.into_static()),
+
extra_headers: self.extra_headers,
+
}
+
}
+
}
+
/// Extension for stateless XRPC calls on any `HttpClient`.
///
/// Example
···
}
/// Send the given typed XRPC request and return a response wrapper.
-
pub async fn send<R: XrpcRequest + Send>(self, request: R) -> XrpcResult<Response<R>> {
-
let http_request = build_http_request(&self.base, &request, &self.opts)
+
pub async fn send<R: XrpcRequest + Send>(self, request: &R) -> XrpcResult<Response<R>> {
+
let http_request = build_http_request(&self.base, request, &self.opts)
.map_err(crate::error::TransportError::from)?;
let http_response = self
···
#[error("Failed to decode response: {0}")]
Decode(#[from] serde_json::Error),
}
+
+
/// Stateful XRPC call trait
+
pub trait XrpcClient: HttpClient {
+
/// Get the base URI for the client.
+
fn base_uri(&self) -> Url;
+
+
/// Get the call options for the client.
+
fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
+
async { CallOptions::default() }
+
}
+
/// Send an XRPC request and parse the response
+
fn send<R: XrpcRequest + Send>(
+
self,
+
request: &R,
+
) -> impl Future<Output = XrpcResult<Response<R>>>;
+
}
+2 -2
crates/jacquard-identity/src/lib.rs
···
let resp = self
.http
.xrpc(pds)
-
.send(req)
+
.send(&req)
.await
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
let out = resp
···
let resp = self
.http
.xrpc(pds)
-
.send(req)
+
.send(&req)
.await
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
let out = resp
+37 -1
crates/jacquard-oauth/src/authstore.rs
···
-
use jacquard_common::{session::SessionStoreError, types::did::Did};
+
use std::sync::Arc;
+
+
use jacquard_common::{
+
IntoStatic,
+
session::{FileTokenStore, SessionStore, SessionStoreError},
+
types::did::Did,
+
};
+
use smol_str::SmolStr;
use crate::session::{AuthRequestData, ClientSessionData};
···
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>;
}
+
+
#[async_trait::async_trait]
+
impl<T: ClientAuthStore + Send + Sync>
+
SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T>
+
{
+
/// Get the current session if present.
+
async fn get(&self, key: &(Did<'static>, SmolStr)) -> Option<ClientSessionData<'static>> {
+
let (did, session_id) = key;
+
self.as_ref()
+
.get_session(did, session_id)
+
.await
+
.ok()
+
.flatten()
+
.into_static()
+
}
+
/// Persist the given session.
+
async fn set(
+
&self,
+
_key: (Did<'static>, SmolStr),
+
session: ClientSessionData<'static>,
+
) -> Result<(), SessionStoreError> {
+
self.as_ref().upsert_session(session).await
+
}
+
/// Delete the given session.
+
async fn del(&self, key: &(Did<'static>, SmolStr)) -> Result<(), SessionStoreError> {
+
let (did, session_id) = key;
+
self.as_ref().delete_session(did, session_id).await
+
}
+
}
+177 -15
crates/jacquard-oauth/src/client.rs
···
-
use std::sync::Arc;
-
-
use jacquard_common::{CowStr, IntoStatic, types::did::Did};
+
use jacquard_common::{
+
AuthorizationToken, CowStr, IntoStatic,
+
error::{AuthError, ClientError, TransportError, XrpcResult},
+
http_client::HttpClient,
+
types::{
+
did::Did,
+
xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest},
+
},
+
};
use jose_jwk::JwkSet;
+
use smol_str::SmolStr;
+
use std::sync::Arc;
+
use tokio::sync::RwLock;
use url::Url;
use crate::{
···
.unwrap())
}
-
pub async fn callback(&self, params: CallbackParams<'_>) -> Result<ClientSessionData<'static>> {
+
pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> {
let Some(state_key) = params.state else {
return Err(OAuthError::Callback("missing state parameter".into()));
};
···
token_set,
};
-
Ok(client_data.into_static())
+
self.create_session(client_data).await
}
Err(e) => Err(e.into()),
}
}
-
pub async fn restore(
-
&self,
-
did: &Did<'_>,
-
session_id: &str,
-
) -> Result<ClientSessionData<'static>> {
-
Ok(self
-
.registry
-
.get(did, session_id, false)
-
.await?
-
.into_static())
+
async fn create_session(&self, data: ClientSessionData<'_>) -> Result<OAuthSession<T, S>> {
+
Ok(OAuthSession::new(
+
self.registry.clone(),
+
self.client.clone(),
+
data.into_static(),
+
))
+
}
+
+
pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> {
+
self.create_session(self.registry.get(did, session_id, false).await?)
+
.await
}
pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> {
Ok(self.registry.del(did, session_id).await?)
}
}
+
+
pub struct OAuthSession<T, S>
+
where
+
T: OAuthResolver,
+
S: ClientAuthStore,
+
{
+
pub registry: Arc<SessionRegistry<T, S>>,
+
pub client: Arc<T>,
+
pub data: RwLock<ClientSessionData<'static>>,
+
pub options: RwLock<CallOptions<'static>>,
+
}
+
+
impl<T, S> OAuthSession<T, S>
+
where
+
T: OAuthResolver,
+
S: ClientAuthStore,
+
{
+
pub fn new(
+
registry: Arc<SessionRegistry<T, S>>,
+
client: Arc<T>,
+
data: ClientSessionData<'static>,
+
) -> Self {
+
Self {
+
registry,
+
client,
+
data: RwLock::new(data),
+
options: RwLock::new(CallOptions::default()),
+
}
+
}
+
+
pub fn with_options(self, options: CallOptions<'_>) -> Self {
+
Self {
+
registry: self.registry,
+
client: self.client,
+
data: self.data,
+
options: RwLock::new(options.into_static()),
+
}
+
}
+
+
pub async fn set_options(&self, options: CallOptions<'_>) {
+
*self.options.write().await = options.into_static();
+
}
+
+
pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) {
+
let data = self.data.read().await;
+
(data.account_did.clone(), data.session_id.clone())
+
}
+
+
pub async fn pds(&self) -> Url {
+
self.data.read().await.host_url.clone()
+
}
+
+
pub async fn access_token(&self) -> AuthorizationToken<'_> {
+
AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone())
+
}
+
+
pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
+
self.data
+
.read()
+
.await
+
.token_set
+
.refresh_token
+
.as_ref()
+
.map(|token| AuthorizationToken::Dpop(token.clone()))
+
}
+
}
+
impl<T, S> OAuthSession<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + Send + Sync + 'static,
+
{
+
pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> {
+
let mut data = self.data.write().await;
+
let refreshed = self
+
.registry
+
.as_ref()
+
.get(&data.account_did, &data.session_id, true)
+
.await?;
+
let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone());
+
*data = refreshed.into_static();
+
Ok(token)
+
}
+
}
+
+
impl<T, S> HttpClient for OAuthSession<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + 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<T, S> XrpcClient for OAuthSession<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
+
{
+
fn base_uri(&self) -> Url {
+
self.data.blocking_read().host_url.clone()
+
}
+
+
async fn opts(&self) -> CallOptions<'_> {
+
self.options.read().await.clone()
+
}
+
+
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 = Some(auth);
+
let res = self
+
.client
+
.xrpc(base_uri.clone())
+
.with_options(opts.clone())
+
.send(request)
+
.await;
+
if is_invalid_token_response(&res) {
+
opts.auth = Some(
+
self.refresh()
+
.await
+
.map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?,
+
);
+
self.client
+
.xrpc(base_uri)
+
.with_options(opts)
+
.send(request)
+
.await
+
} else {
+
res
+
}
+
}
+
}
+
+
fn is_invalid_token_response<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool {
+
match response {
+
Err(ClientError::Auth(AuthError::InvalidToken)) => true,
+
Err(ClientError::Auth(AuthError::Other(value))) => value
+
.to_str()
+
.is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")),
+
_ => false,
+
}
+
}
+29
crates/jacquard-oauth/src/session.rs
···
pub dpop_data: DpopReqData<'s>,
}
+
impl IntoStatic for AuthRequestData<'_> {
+
type Output = AuthRequestData<'static>;
+
fn into_static(self) -> AuthRequestData<'static> {
+
AuthRequestData {
+
request_uri: self.request_uri.into_static(),
+
authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
+
authserver_revocation_endpoint: self
+
.authserver_revocation_endpoint
+
.map(|s| s.into_static()),
+
pkce_verifier: self.pkce_verifier.into_static(),
+
dpop_data: self.dpop_data.into_static(),
+
state: self.state.into_static(),
+
authserver_url: self.authserver_url,
+
account_did: self.account_did.into_static(),
+
scopes: self.scopes.into_static(),
+
}
+
}
+
}
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DpopReqData<'s> {
// The secret cryptographic key generated by the client for this specific OAuth session
···
// Server-provided DPoP nonce from auth request (PAR)
#[serde(borrow)]
pub dpop_authserver_nonce: Option<CowStr<'s>>,
+
}
+
+
impl IntoStatic for DpopReqData<'_> {
+
type Output = DpopReqData<'static>;
+
fn into_static(self) -> DpopReqData<'static> {
+
DpopReqData {
+
dpop_key: self.dpop_key,
+
dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(),
+
}
+
}
}
impl DpopDataSource for DpopReqData<'_> {
+9
crates/jacquard-oauth/src/types/response.rs
···
Bearer,
}
+
impl OAuthTokenType {
+
pub fn as_str(&self) -> &'static str {
+
match self {
+
OAuthTokenType::DPoP => "DPoP",
+
OAuthTokenType::Bearer => "Bearer",
+
}
+
}
+
}
+
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct OAuthTokenResponse {
+1 -6
crates/jacquard/src/client.rs
···
xrpc::{Response, XrpcRequest},
},
};
-
pub use token::FileTokenStore;
+
pub use token::FileAuthStore;
use url::Url;
// Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs
···
session: AuthSession,
) -> core::result::Result<(), SessionStoreError> {
self.0.set_session(session).await
-
}
-
-
/// Clear session.
-
pub async fn clear_session(&self) -> core::result::Result<(), SessionStoreError> {
-
self.0.clear_session().await
}
/// Base URL of this client.
+15 -10
crates/jacquard/src/client/at_client.rs
···
session::{SessionStore, SessionStoreError},
types::{
did::Did,
-
xrpc::{CallOptions, Response, XrpcExt},
+
xrpc::{CallOptions, Response, XrpcExt, XrpcRequest, build_http_request},
},
};
use url::Url;
-
-
use jacquard_common::types::xrpc::{XrpcRequest, build_http_request};
use crate::client::{AtpSession, AuthSession, NSID_REFRESH_SESSION};
/// Per-call overrides when sending via `AtClient`.
-
#[derive(Debug, Default, Clone)]
+
#[derive(Debug, Clone)]
pub struct SendOverrides<'a> {
+
/// Optional DID override for this call.
pub did: Option<Did<'a>>,
/// Optional base URI override for this call.
pub base_uri: Option<Url>,
···
pub auto_refresh: bool,
}
+
impl Default for SendOverrides<'_> {
+
fn default() -> Self {
+
Self {
+
did: None,
+
base_uri: None,
+
options: CallOptions::default(),
+
auto_refresh: true,
+
}
+
}
+
}
+
impl<'a> SendOverrides<'a> {
/// Construct default overrides (no base override, auto-refresh enabled).
pub fn new() -> Self {
···
let did = s.did().clone().into_static();
self.refresh_lock.lock().await.replace(did.clone());
self.tokens.set(did, session).await
-
}
-
-
/// Clear the current session from the token store.
-
pub async fn clear_session(&self) -> Result<(), SessionStoreError> {
-
self.tokens.clear().await
}
/// Send an XRPC request using the client's base URL and default behavior.
···
.auth(AuthorizationToken::Bearer(
refresh_tok.clone().into_static(),
))
-
.send(jacquard_api::com_atproto::server::refresh_session::RefreshSession)
+
.send(&jacquard_api::com_atproto::server::refresh_session::RefreshSession)
.await?;
let refreshed = match refresh_resp.into_output() {
Ok(o) => AtpSession::from(o),
+287 -74
crates/jacquard/src/client/token.rs
···
-
use crate::client::AtpSession;
-
use async_trait::async_trait;
use jacquard_common::IntoStatic;
-
use jacquard_common::session::{SessionStore, SessionStoreError};
-
use jacquard_common::types::string::{Did, Handle};
+
use jacquard_common::cowstr::ToCowStr;
+
use jacquard_common::session::{FileTokenStore, SessionStore, SessionStoreError};
+
use jacquard_common::types::string::{Datetime, Did, Handle};
+
use jacquard_oauth::scopes::Scope;
+
use jacquard_oauth::session::{AuthRequestData, ClientSessionData, DpopClientData, DpopReqData};
+
use jacquard_oauth::types::OAuthTokenType;
+
use jose_jwk::Key;
+
use serde::de::DeserializeOwned;
+
use serde::{Deserialize, Serialize};
+
use serde_json::Value;
+
use std::fmt::Display;
+
use std::hash::Hash;
use std::path::{Path, PathBuf};
+
use url::Url;
-
/// File-backed token store using a JSON file.
-
///
-
/// Example
-
/// ```ignore
-
/// use jacquard::client::{AtClient, FileTokenStore};
-
/// let base = url::Url::parse("https://bsky.social").unwrap();
-
/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
-
/// let client = AtClient::new(reqwest::Client::new(), base, store);
-
/// ```
-
#[derive(Clone, Debug)]
-
pub struct FileTokenStore {
-
path: PathBuf,
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+
enum StoredSession {
+
Atp(StoredAtSession),
+
OAuth(OAuthSession),
+
OAuthState(OAuthState),
+
}
+
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+
struct StoredAtSession {
+
access_jwt: String,
+
refresh_jwt: String,
+
did: String,
+
pds: String,
+
session_id: String,
+
handle: String,
+
}
+
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+
struct OAuthSession {
+
account_did: String,
+
session_id: String,
+
+
// Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
+
host_url: Url,
+
+
// Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
+
authserver_url: Url,
+
+
// Full token endpoint
+
authserver_token_endpoint: String,
+
+
// Full revocation endpoint, if it exists
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
authserver_revocation_endpoint: Option<String>,
+
+
// The set of scopes approved for this session (returned in the initial token request)
+
scopes: Vec<String>,
+
+
pub dpop_key: Key,
+
// Current auth server DPoP nonce
+
pub dpop_authserver_nonce: String,
+
// Current host ("resource server", eg PDS) DPoP nonce
+
pub dpop_host_nonce: String,
+
+
pub iss: String,
+
pub sub: String,
+
pub aud: String,
+
pub scope: Option<String>,
+
+
pub refresh_token: Option<String>,
+
pub access_token: String,
+
pub token_type: OAuthTokenType,
+
+
pub expires_at: Option<Datetime>,
+
}
+
+
impl From<ClientSessionData<'_>> for OAuthSession {
+
fn from(data: ClientSessionData<'_>) -> Self {
+
OAuthSession {
+
account_did: data.account_did.to_string(),
+
session_id: data.session_id.to_string(),
+
host_url: data.host_url,
+
authserver_url: data.authserver_url,
+
authserver_token_endpoint: data.authserver_token_endpoint.to_string(),
+
authserver_revocation_endpoint: data
+
.authserver_revocation_endpoint
+
.map(|s| s.to_string()),
+
scopes: data.scopes.into_iter().map(|s| s.to_string()).collect(),
+
dpop_key: data.dpop_data.dpop_key,
+
dpop_authserver_nonce: data.dpop_data.dpop_authserver_nonce.to_string(),
+
dpop_host_nonce: data.dpop_data.dpop_host_nonce.to_string(),
+
iss: data.token_set.iss.to_string(),
+
sub: data.token_set.sub.to_string(),
+
aud: data.token_set.aud.to_string(),
+
scope: data.token_set.scope.map(|s| s.to_string()),
+
refresh_token: data.token_set.refresh_token.map(|s| s.to_string()),
+
access_token: data.token_set.access_token.to_string(),
+
token_type: data.token_set.token_type,
+
expires_at: data.token_set.expires_at,
+
}
+
}
}
-
impl FileTokenStore {
-
/// Create a new file token store at the given path.
-
pub fn new(path: impl AsRef<Path>) -> Self {
-
Self {
-
path: path.as_ref().to_path_buf(),
+
impl From<OAuthSession> for ClientSessionData<'_> {
+
fn from(session: OAuthSession) -> Self {
+
ClientSessionData {
+
account_did: session.account_did.into(),
+
session_id: session.session_id.to_cowstr(),
+
host_url: session.host_url,
+
authserver_url: session.authserver_url,
+
authserver_token_endpoint: session.authserver_token_endpoint.to_cowstr(),
+
authserver_revocation_endpoint: session
+
.authserver_revocation_endpoint
+
.map(|s| s.to_cowstr().into_static()),
+
scopes: session
+
.scopes
+
.into_iter()
+
.map(|s| Scope::parse(&s).unwrap().into_static())
+
.collect(),
+
dpop_data: DpopClientData {
+
dpop_key: session.dpop_key,
+
dpop_authserver_nonce: session.dpop_authserver_nonce.to_cowstr(),
+
dpop_host_nonce: session.dpop_host_nonce.to_cowstr(),
+
},
+
token_set: jacquard_oauth::types::TokenSet {
+
iss: session.iss.into(),
+
sub: session.sub.into(),
+
aud: session.aud.into(),
+
scope: session.scope.map(|s| s.into()),
+
refresh_token: session.refresh_token.map(|s| s.into()),
+
access_token: session.access_token.into(),
+
token_type: session.token_type,
+
expires_at: session.expires_at,
+
},
}
+
.into_static()
}
}
-
#[derive(serde::Serialize, serde::Deserialize)]
-
struct FileSession {
-
access_jwt: String,
-
refresh_jwt: String,
-
did: String,
-
handle: String,
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct OAuthState {
+
// The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
+
pub state: String,
+
+
// URL of the auth server (eg, PDS or entryway)
+
pub authserver_url: Url,
+
+
// If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub account_did: Option<String>,
+
+
// OAuth scope strings
+
pub scopes: Vec<String>,
+
+
// unique token in URI format, which will be used by the client in the auth flow redirect
+
pub request_uri: String,
+
+
// Full token endpoint URL
+
pub authserver_token_endpoint: String,
+
+
// Full revocation endpoint, if it exists
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub authserver_revocation_endpoint: Option<String>,
+
+
// The secret token/nonce which a code challenge was generated from
+
pub pkce_verifier: String,
+
+
pub dpop_key: Key,
+
// Current auth server DPoP nonce
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub dpop_authserver_nonce: Option<String>,
}
-
#[async_trait]
-
impl SessionStore<Did<'static>, AtpSession> for FileTokenStore {
-
async fn get(&self, key: &Did<'static>) -> Option<AtpSession> {
-
let mut path = self.path.clone();
-
path.push(key.to_string());
-
let data = tokio::fs::read(&path).await.ok()?;
-
let disk: FileSession = serde_json::from_slice(&data).ok()?;
-
let did = Did::new_owned(disk.did).ok()?;
-
let handle = Handle::new_owned(disk.handle).ok()?;
-
Some(AtpSession {
-
access_jwt: disk.access_jwt.into(),
-
refresh_jwt: disk.refresh_jwt.into(),
-
did: did.into_static(),
-
handle: handle.into_static(),
-
})
+
impl From<AuthRequestData<'_>> for OAuthState {
+
fn from(value: AuthRequestData) -> Self {
+
OAuthState {
+
authserver_url: value.authserver_url,
+
account_did: value.account_did.map(|s| s.to_string()),
+
scopes: value.scopes.into_iter().map(|s| s.to_string()).collect(),
+
request_uri: value.request_uri.to_string(),
+
authserver_token_endpoint: value.authserver_token_endpoint.to_string(),
+
authserver_revocation_endpoint: value
+
.authserver_revocation_endpoint
+
.map(|s| s.to_string()),
+
pkce_verifier: value.pkce_verifier.to_string(),
+
dpop_key: value.dpop_data.dpop_key,
+
dpop_authserver_nonce: value.dpop_data.dpop_authserver_nonce.map(|s| s.to_string()),
+
state: value.state.to_string(),
+
}
}
+
}
-
async fn set(&self, key: Did<'static>, session: AtpSession) -> Result<(), SessionStoreError> {
-
let disk = FileSession {
-
access_jwt: session.access_jwt.to_string(),
-
refresh_jwt: session.refresh_jwt.to_string(),
-
did: session.did.to_string(),
-
handle: session.handle.to_string(),
-
};
-
let buf = serde_json::to_vec_pretty(&disk).map_err(SessionStoreError::from)?;
-
if let Some(parent) = self.path.parent() {
-
tokio::fs::create_dir_all(parent)
-
.await
-
.map_err(SessionStoreError::from)?;
+
impl From<OAuthState> for AuthRequestData<'_> {
+
fn from(value: OAuthState) -> Self {
+
AuthRequestData {
+
authserver_url: value.authserver_url,
+
state: value.state.to_cowstr(),
+
account_did: value.account_did.map(|s| Did::from(s).into_static()),
+
authserver_revocation_endpoint: value
+
.authserver_revocation_endpoint
+
.map(|s| s.to_cowstr().into_static()),
+
scopes: value
+
.scopes
+
.into_iter()
+
.map(|s| Scope::parse(&s).unwrap().into_static())
+
.collect(),
+
request_uri: value.request_uri.to_cowstr(),
+
authserver_token_endpoint: value.authserver_token_endpoint.to_cowstr(),
+
pkce_verifier: value.pkce_verifier.to_cowstr(),
+
dpop_data: DpopReqData {
+
dpop_key: value.dpop_key,
+
dpop_authserver_nonce: value
+
.dpop_authserver_nonce
+
.map(|s| s.to_cowstr().into_static()),
+
},
}
-
let mut path = self.path.clone();
-
path.push(key.to_string());
-
let tmp = path.with_extension("tmp");
-
tokio::fs::write(&tmp, &buf)
-
.await
-
.map_err(SessionStoreError::from)?;
-
tokio::fs::rename(&tmp, &path)
+
.into_static()
+
}
+
}
+
+
pub struct FileAuthStore(FileTokenStore);
+
+
#[async_trait::async_trait]
+
impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore {
+
async fn get_session(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> {
+
let key = format!("{}_{}", did, session_id);
+
if let StoredSession::OAuth(session) = self
+
.0
+
.get(&key)
.await
-
.map_err(SessionStoreError::from)?;
+
.ok_or(SessionStoreError::Other("not found".into()))?
+
{
+
Ok(Some(session.into()))
+
} else {
+
Ok(None)
+
}
+
}
+
+
async fn upsert_session(
+
&self,
+
session: ClientSessionData<'_>,
+
) -> Result<(), SessionStoreError> {
+
let key = format!("{}_{}", session.account_did, session.session_id);
+
self.0
+
.set(key, StoredSession::OAuth(session.into()))
+
.await?;
Ok(())
}
-
async fn del(&self, key: &Did<'static>) -> Result<(), SessionStoreError> {
-
let mut path = self.path.clone();
-
path.push(key.to_string());
-
match tokio::fs::remove_file(&path).await {
-
Ok(_) => Ok(()),
-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
-
Err(e) => Err(SessionStoreError::from(e)),
+
async fn delete_session(
+
&self,
+
did: &Did<'_>,
+
session_id: &str,
+
) -> Result<(), SessionStoreError> {
+
let key = format!("{}_{}", did, session_id);
+
let file = std::fs::read_to_string(&self.0.path)?;
+
let mut store: Value = serde_json::from_str(&file)?;
+
let key_string = key.to_string();
+
if let Some(store) = store.as_object_mut() {
+
store.remove(&key_string);
+
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
+
Ok(())
+
} else {
+
Err(SessionStoreError::Other("invalid store".into()))
}
}
-
async fn clear(&self) -> Result<(), SessionStoreError> {
-
match tokio::fs::remove_file(&self.path).await {
-
Ok(_) => Ok(()),
-
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
-
Err(e) => Err(SessionStoreError::from(e)),
+
async fn get_auth_req_info(
+
&self,
+
state: &str,
+
) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> {
+
let key = format!("authreq_{}", state);
+
if let StoredSession::OAuthState(auth_req) = self
+
.0
+
.get(&key)
+
.await
+
.ok_or(SessionStoreError::Other("not found".into()))?
+
{
+
Ok(Some(auth_req.into()))
+
} else {
+
Ok(None)
+
}
+
}
+
+
async fn save_auth_req_info(
+
&self,
+
auth_req_info: &AuthRequestData<'_>,
+
) -> Result<(), SessionStoreError> {
+
let key = format!("authreq_{}", auth_req_info.state);
+
self.0
+
.set(key, StoredSession::OAuthState(auth_req_info.clone().into()))
+
.await?;
+
Ok(())
+
}
+
+
async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> {
+
let key = format!("authreq_{}", state);
+
let file = std::fs::read_to_string(&self.0.path)?;
+
let mut store: Value = serde_json::from_str(&file)?;
+
let key_string = key.to_string();
+
if let Some(store) = store.as_object_mut() {
+
store.remove(&key_string);
+
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
+
Ok(())
+
} else {
+
Err(SessionStoreError::Other("invalid store".into()))
}
}
}
+1 -1
crates/jacquard/src/main.rs
···
use jacquard::CowStr;
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
use jacquard::api::com_atproto::server::create_session::CreateSession;
-
use jacquard::client::{AtpSession, AuthSession, BasicClient};
+
use jacquard::client::{AtpSession, BasicClient};
use jacquard::identity::resolver::IdentityResolver;
use jacquard::identity::slingshot_resolver_default;
use jacquard::types::string::Handle;