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
··· 30 /// DPoP token (proof-of-possession) for OAuth 31 Dpop(CowStr<'s>), 32 }
··· 30 /// DPoP token (proof-of-possession) for OAuth 31 Dpop(CowStr<'s>), 32 } 33 + 34 + impl<'s> IntoStatic for AuthorizationToken<'s> { 35 + type Output = AuthorizationToken<'static>; 36 + fn into_static(self) -> AuthorizationToken<'static> { 37 + match self { 38 + AuthorizationToken::Bearer(token) => AuthorizationToken::Bearer(token.into_static()), 39 + AuthorizationToken::Dpop(token) => AuthorizationToken::Dpop(token.into_static()), 40 + } 41 + } 42 + }
+89 -5
crates/jacquard-common/src/session.rs
··· 2 3 use async_trait::async_trait; 4 use miette::Diagnostic; 5 use std::collections::HashMap; 6 use std::error::Error as StdError; 7 use std::hash::Hash; 8 use std::sync::Arc; 9 use tokio::sync::RwLock; 10 11 /// Errors emitted by session stores. 12 #[derive(Debug, thiserror::Error, Diagnostic)] ··· 38 async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>; 39 /// Delete the given session. 40 async fn del(&self, key: &K) -> Result<(), SessionStoreError>; 41 - /// Remove all stored sessions. 42 - async fn clear(&self) -> Result<(), SessionStoreError>; 43 } 44 45 /// In-memory session store suitable for short-lived sessions and tests. ··· 69 self.0.write().await.remove(key); 70 Ok(()) 71 } 72 - async fn clear(&self) -> Result<(), SessionStoreError> { 73 - self.0.write().await.clear(); 74 - Ok(()) 75 } 76 }
··· 2 3 use async_trait::async_trait; 4 use miette::Diagnostic; 5 + use serde::Serialize; 6 + use serde::de::DeserializeOwned; 7 + use serde_json::Value; 8 use std::collections::HashMap; 9 use std::error::Error as StdError; 10 + use std::fmt::Display; 11 use std::hash::Hash; 12 + use std::path::{Path, PathBuf}; 13 use std::sync::Arc; 14 use tokio::sync::RwLock; 15 + use url::Url; 16 + 17 + use crate::AuthorizationToken; 18 + use crate::types::did::Did; 19 + 20 + #[async_trait::async_trait] 21 + pub trait Session { 22 + async fn did(&self) -> Did<'_>; 23 + 24 + async fn endpoint(&self) -> Url; 25 + 26 + async fn access_token(&self) -> Result<AuthorizationToken, SessionStoreError>; 27 + 28 + async fn refresh(&self) -> Result<(), SessionStoreError>; 29 + } 30 31 /// Errors emitted by session stores. 32 #[derive(Debug, thiserror::Error, Diagnostic)] ··· 58 async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>; 59 /// Delete the given session. 60 async fn del(&self, key: &K) -> Result<(), SessionStoreError>; 61 } 62 63 /// In-memory session store suitable for short-lived sessions and tests. ··· 87 self.0.write().await.remove(key); 88 Ok(()) 89 } 90 + } 91 + 92 + /// File-backed token store using a JSON file. 93 + /// 94 + /// NOT secure, only suitable for development. 95 + /// 96 + /// Example 97 + /// ```ignore 98 + /// use jacquard::client::{AtClient, FileTokenStore}; 99 + /// let base = url::Url::parse("https://bsky.social").unwrap(); 100 + /// let store = FileTokenStore::new("/tmp/jacquard-session.json"); 101 + /// let client = AtClient::new(reqwest::Client::new(), base, store); 102 + /// ``` 103 + #[derive(Clone, Debug)] 104 + pub struct FileTokenStore { 105 + /// Path to the JSON file. 106 + pub path: PathBuf, 107 + } 108 + 109 + impl FileTokenStore { 110 + /// Create a new file token store at the given path. 111 + pub fn new(path: impl AsRef<Path>) -> Self { 112 + Self { 113 + path: path.as_ref().to_path_buf(), 114 + } 115 + } 116 + } 117 + 118 + #[async_trait::async_trait] 119 + impl< 120 + K: Eq + Hash + Display + Send + Sync + 'static, 121 + T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static, 122 + > SessionStore<K, T> for FileTokenStore 123 + { 124 + /// Get the current session if present. 125 + async fn get(&self, key: &K) -> Option<T> { 126 + let file = std::fs::read_to_string(&self.path).ok()?; 127 + let store: Value = serde_json::from_str(&file).ok()?; 128 + 129 + let session = store.get(key.to_string())?; 130 + serde_json::from_value(session.clone()).ok() 131 + } 132 + /// Persist the given session. 133 + async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> { 134 + let file = std::fs::read_to_string(&self.path)?; 135 + let mut store: Value = serde_json::from_str(&file)?; 136 + let key_string = key.to_string(); 137 + if let Some(store) = store.as_object_mut() { 138 + store.insert(key_string, serde_json::to_value(session.clone())?); 139 + 140 + std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?; 141 + Ok(()) 142 + } else { 143 + Err(SessionStoreError::Other("invalid store".into())) 144 + } 145 + } 146 + /// Delete the given session. 147 + async fn del(&self, key: &K) -> Result<(), SessionStoreError> { 148 + let file = std::fs::read_to_string(&self.path)?; 149 + let mut store: Value = serde_json::from_str(&file)?; 150 + let key_string = key.to_string(); 151 + if let Some(store) = store.as_object_mut() { 152 + store.remove(&key_string); 153 + 154 + std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?; 155 + Ok(()) 156 + } else { 157 + Err(SessionStoreError::Other("invalid store".into())) 158 + } 159 } 160 }
+33 -2
crates/jacquard-common/src/types/xrpc.rs
··· 128 pub extra_headers: Vec<(HeaderName, HeaderValue)>, 129 } 130 131 /// Extension for stateless XRPC calls on any `HttpClient`. 132 /// 133 /// Example ··· 236 } 237 238 /// Send the given typed XRPC request and return a response wrapper. 239 - pub async fn send<R: XrpcRequest + Send>(self, request: R) -> XrpcResult<Response<R>> { 240 - let http_request = build_http_request(&self.base, &request, &self.opts) 241 .map_err(crate::error::TransportError::from)?; 242 243 let http_response = self ··· 548 #[error("Failed to decode response: {0}")] 549 Decode(#[from] serde_json::Error), 550 }
··· 128 pub extra_headers: Vec<(HeaderName, HeaderValue)>, 129 } 130 131 + impl IntoStatic for CallOptions<'_> { 132 + type Output = CallOptions<'static>; 133 + 134 + fn into_static(self) -> Self::Output { 135 + CallOptions { 136 + auth: self.auth.map(|auth| auth.into_static()), 137 + atproto_proxy: self.atproto_proxy.map(|proxy| proxy.into_static()), 138 + atproto_accept_labelers: self 139 + .atproto_accept_labelers 140 + .map(|labelers| labelers.into_static()), 141 + extra_headers: self.extra_headers, 142 + } 143 + } 144 + } 145 + 146 /// Extension for stateless XRPC calls on any `HttpClient`. 147 /// 148 /// Example ··· 251 } 252 253 /// Send the given typed XRPC request and return a response wrapper. 254 + pub async fn send<R: XrpcRequest + Send>(self, request: &R) -> XrpcResult<Response<R>> { 255 + let http_request = build_http_request(&self.base, request, &self.opts) 256 .map_err(crate::error::TransportError::from)?; 257 258 let http_response = self ··· 563 #[error("Failed to decode response: {0}")] 564 Decode(#[from] serde_json::Error), 565 } 566 + 567 + /// Stateful XRPC call trait 568 + pub trait XrpcClient: HttpClient { 569 + /// Get the base URI for the client. 570 + fn base_uri(&self) -> Url; 571 + 572 + /// Get the call options for the client. 573 + fn opts(&self) -> impl Future<Output = CallOptions<'_>> { 574 + async { CallOptions::default() } 575 + } 576 + /// Send an XRPC request and parse the response 577 + fn send<R: XrpcRequest + Send>( 578 + self, 579 + request: &R, 580 + ) -> impl Future<Output = XrpcResult<Response<R>>>; 581 + }
+2 -2
crates/jacquard-identity/src/lib.rs
··· 203 let resp = self 204 .http 205 .xrpc(pds) 206 - .send(req) 207 .await 208 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 209 let out = resp ··· 227 let resp = self 228 .http 229 .xrpc(pds) 230 - .send(req) 231 .await 232 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 233 let out = resp
··· 203 let resp = self 204 .http 205 .xrpc(pds) 206 + .send(&req) 207 .await 208 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 209 let out = resp ··· 227 let resp = self 228 .http 229 .xrpc(pds) 230 + .send(&req) 231 .await 232 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 233 let out = resp
+37 -1
crates/jacquard-oauth/src/authstore.rs
··· 1 - use jacquard_common::{session::SessionStoreError, types::did::Did}; 2 3 use crate::session::{AuthRequestData, ClientSessionData}; 4 ··· 31 32 async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>; 33 }
··· 1 + use std::sync::Arc; 2 + 3 + use jacquard_common::{ 4 + IntoStatic, 5 + session::{FileTokenStore, SessionStore, SessionStoreError}, 6 + types::did::Did, 7 + }; 8 + use smol_str::SmolStr; 9 10 use crate::session::{AuthRequestData, ClientSessionData}; 11 ··· 38 39 async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>; 40 } 41 + 42 + #[async_trait::async_trait] 43 + impl<T: ClientAuthStore + Send + Sync> 44 + SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T> 45 + { 46 + /// Get the current session if present. 47 + async fn get(&self, key: &(Did<'static>, SmolStr)) -> Option<ClientSessionData<'static>> { 48 + let (did, session_id) = key; 49 + self.as_ref() 50 + .get_session(did, session_id) 51 + .await 52 + .ok() 53 + .flatten() 54 + .into_static() 55 + } 56 + /// Persist the given session. 57 + async fn set( 58 + &self, 59 + _key: (Did<'static>, SmolStr), 60 + session: ClientSessionData<'static>, 61 + ) -> Result<(), SessionStoreError> { 62 + self.as_ref().upsert_session(session).await 63 + } 64 + /// Delete the given session. 65 + async fn del(&self, key: &(Did<'static>, SmolStr)) -> Result<(), SessionStoreError> { 66 + let (did, session_id) = key; 67 + self.as_ref().delete_session(did, session_id).await 68 + } 69 + }
+177 -15
crates/jacquard-oauth/src/client.rs
··· 1 - use std::sync::Arc; 2 - 3 - use jacquard_common::{CowStr, IntoStatic, types::did::Did}; 4 use jose_jwk::JwkSet; 5 use url::Url; 6 7 use crate::{ ··· 88 .unwrap()) 89 } 90 91 - pub async fn callback(&self, params: CallbackParams<'_>) -> Result<ClientSessionData<'static>> { 92 let Some(state_key) = params.state else { 93 return Err(OAuthError::Callback("missing state parameter".into())); 94 }; ··· 162 token_set, 163 }; 164 165 - Ok(client_data.into_static()) 166 } 167 Err(e) => Err(e.into()), 168 } 169 } 170 171 - pub async fn restore( 172 - &self, 173 - did: &Did<'_>, 174 - session_id: &str, 175 - ) -> Result<ClientSessionData<'static>> { 176 - Ok(self 177 - .registry 178 - .get(did, session_id, false) 179 - .await? 180 - .into_static()) 181 } 182 183 pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> { 184 Ok(self.registry.del(did, session_id).await?) 185 } 186 }
··· 1 + use jacquard_common::{ 2 + AuthorizationToken, CowStr, IntoStatic, 3 + error::{AuthError, ClientError, TransportError, XrpcResult}, 4 + http_client::HttpClient, 5 + types::{ 6 + did::Did, 7 + xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest}, 8 + }, 9 + }; 10 use jose_jwk::JwkSet; 11 + use smol_str::SmolStr; 12 + use std::sync::Arc; 13 + use tokio::sync::RwLock; 14 use url::Url; 15 16 use crate::{ ··· 97 .unwrap()) 98 } 99 100 + pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> { 101 let Some(state_key) = params.state else { 102 return Err(OAuthError::Callback("missing state parameter".into())); 103 }; ··· 171 token_set, 172 }; 173 174 + self.create_session(client_data).await 175 } 176 Err(e) => Err(e.into()), 177 } 178 } 179 180 + async fn create_session(&self, data: ClientSessionData<'_>) -> Result<OAuthSession<T, S>> { 181 + Ok(OAuthSession::new( 182 + self.registry.clone(), 183 + self.client.clone(), 184 + data.into_static(), 185 + )) 186 + } 187 + 188 + pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> { 189 + self.create_session(self.registry.get(did, session_id, false).await?) 190 + .await 191 } 192 193 pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> { 194 Ok(self.registry.del(did, session_id).await?) 195 } 196 } 197 + 198 + pub struct OAuthSession<T, S> 199 + where 200 + T: OAuthResolver, 201 + S: ClientAuthStore, 202 + { 203 + pub registry: Arc<SessionRegistry<T, S>>, 204 + pub client: Arc<T>, 205 + pub data: RwLock<ClientSessionData<'static>>, 206 + pub options: RwLock<CallOptions<'static>>, 207 + } 208 + 209 + impl<T, S> OAuthSession<T, S> 210 + where 211 + T: OAuthResolver, 212 + S: ClientAuthStore, 213 + { 214 + pub fn new( 215 + registry: Arc<SessionRegistry<T, S>>, 216 + client: Arc<T>, 217 + data: ClientSessionData<'static>, 218 + ) -> Self { 219 + Self { 220 + registry, 221 + client, 222 + data: RwLock::new(data), 223 + options: RwLock::new(CallOptions::default()), 224 + } 225 + } 226 + 227 + pub fn with_options(self, options: CallOptions<'_>) -> Self { 228 + Self { 229 + registry: self.registry, 230 + client: self.client, 231 + data: self.data, 232 + options: RwLock::new(options.into_static()), 233 + } 234 + } 235 + 236 + pub async fn set_options(&self, options: CallOptions<'_>) { 237 + *self.options.write().await = options.into_static(); 238 + } 239 + 240 + pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) { 241 + let data = self.data.read().await; 242 + (data.account_did.clone(), data.session_id.clone()) 243 + } 244 + 245 + pub async fn pds(&self) -> Url { 246 + self.data.read().await.host_url.clone() 247 + } 248 + 249 + pub async fn access_token(&self) -> AuthorizationToken<'_> { 250 + AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone()) 251 + } 252 + 253 + pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> { 254 + self.data 255 + .read() 256 + .await 257 + .token_set 258 + .refresh_token 259 + .as_ref() 260 + .map(|token| AuthorizationToken::Dpop(token.clone())) 261 + } 262 + } 263 + impl<T, S> OAuthSession<T, S> 264 + where 265 + S: ClientAuthStore + Send + Sync + 'static, 266 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 267 + { 268 + pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> { 269 + let mut data = self.data.write().await; 270 + let refreshed = self 271 + .registry 272 + .as_ref() 273 + .get(&data.account_did, &data.session_id, true) 274 + .await?; 275 + let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone()); 276 + *data = refreshed.into_static(); 277 + Ok(token) 278 + } 279 + } 280 + 281 + impl<T, S> HttpClient for OAuthSession<T, S> 282 + where 283 + S: ClientAuthStore + Send + Sync + 'static, 284 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 285 + { 286 + type Error = T::Error; 287 + 288 + async fn send_http( 289 + &self, 290 + request: http::Request<Vec<u8>>, 291 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 292 + self.client.send_http(request).await 293 + } 294 + } 295 + 296 + impl<T, S> XrpcClient for OAuthSession<T, S> 297 + where 298 + S: ClientAuthStore + Send + Sync + 'static, 299 + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 300 + { 301 + fn base_uri(&self) -> Url { 302 + self.data.blocking_read().host_url.clone() 303 + } 304 + 305 + async fn opts(&self) -> CallOptions<'_> { 306 + self.options.read().await.clone() 307 + } 308 + 309 + async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>( 310 + self, 311 + request: &R, 312 + ) -> XrpcResult<Response<R>> { 313 + let base_uri = self.base_uri(); 314 + let auth = self.access_token().await; 315 + let mut opts = self.options.read().await.clone(); 316 + opts.auth = Some(auth); 317 + let res = self 318 + .client 319 + .xrpc(base_uri.clone()) 320 + .with_options(opts.clone()) 321 + .send(request) 322 + .await; 323 + if is_invalid_token_response(&res) { 324 + opts.auth = Some( 325 + self.refresh() 326 + .await 327 + .map_err(|e| ClientError::Transport(TransportError::Other(e.into())))?, 328 + ); 329 + self.client 330 + .xrpc(base_uri) 331 + .with_options(opts) 332 + .send(request) 333 + .await 334 + } else { 335 + res 336 + } 337 + } 338 + } 339 + 340 + fn is_invalid_token_response<R: XrpcRequest>(response: &XrpcResult<Response<R>>) -> bool { 341 + match response { 342 + Err(ClientError::Auth(AuthError::InvalidToken)) => true, 343 + Err(ClientError::Auth(AuthError::Other(value))) => value 344 + .to_str() 345 + .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), 346 + _ => false, 347 + } 348 + }
+29
crates/jacquard-oauth/src/session.rs
··· 174 pub dpop_data: DpopReqData<'s>, 175 } 176 177 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 178 pub struct DpopReqData<'s> { 179 // The secret cryptographic key generated by the client for this specific OAuth session ··· 181 // Server-provided DPoP nonce from auth request (PAR) 182 #[serde(borrow)] 183 pub dpop_authserver_nonce: Option<CowStr<'s>>, 184 } 185 186 impl DpopDataSource for DpopReqData<'_> {
··· 174 pub dpop_data: DpopReqData<'s>, 175 } 176 177 + impl IntoStatic for AuthRequestData<'_> { 178 + type Output = AuthRequestData<'static>; 179 + fn into_static(self) -> AuthRequestData<'static> { 180 + AuthRequestData { 181 + request_uri: self.request_uri.into_static(), 182 + authserver_token_endpoint: self.authserver_token_endpoint.into_static(), 183 + authserver_revocation_endpoint: self 184 + .authserver_revocation_endpoint 185 + .map(|s| s.into_static()), 186 + pkce_verifier: self.pkce_verifier.into_static(), 187 + dpop_data: self.dpop_data.into_static(), 188 + state: self.state.into_static(), 189 + authserver_url: self.authserver_url, 190 + account_did: self.account_did.into_static(), 191 + scopes: self.scopes.into_static(), 192 + } 193 + } 194 + } 195 + 196 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 197 pub struct DpopReqData<'s> { 198 // The secret cryptographic key generated by the client for this specific OAuth session ··· 200 // Server-provided DPoP nonce from auth request (PAR) 201 #[serde(borrow)] 202 pub dpop_authserver_nonce: Option<CowStr<'s>>, 203 + } 204 + 205 + impl IntoStatic for DpopReqData<'_> { 206 + type Output = DpopReqData<'static>; 207 + fn into_static(self) -> DpopReqData<'static> { 208 + DpopReqData { 209 + dpop_key: self.dpop_key, 210 + dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(), 211 + } 212 + } 213 } 214 215 impl DpopDataSource for DpopReqData<'_> {
+9
crates/jacquard-oauth/src/types/response.rs
··· 13 Bearer, 14 } 15 16 // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 17 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 18 pub struct OAuthTokenResponse {
··· 13 Bearer, 14 } 15 16 + impl OAuthTokenType { 17 + pub fn as_str(&self) -> &'static str { 18 + match self { 19 + OAuthTokenType::DPoP => "DPoP", 20 + OAuthTokenType::Bearer => "Bearer", 21 + } 22 + } 23 + } 24 + 25 // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 26 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 27 pub struct OAuthTokenResponse {
+1 -6
crates/jacquard/src/client.rs
··· 18 xrpc::{Response, XrpcRequest}, 19 }, 20 }; 21 - pub use token::FileTokenStore; 22 use url::Url; 23 24 // Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs ··· 70 session: AuthSession, 71 ) -> core::result::Result<(), SessionStoreError> { 72 self.0.set_session(session).await 73 - } 74 - 75 - /// Clear session. 76 - pub async fn clear_session(&self) -> core::result::Result<(), SessionStoreError> { 77 - self.0.clear_session().await 78 } 79 80 /// Base URL of this client.
··· 18 xrpc::{Response, XrpcRequest}, 19 }, 20 }; 21 + pub use token::FileAuthStore; 22 use url::Url; 23 24 // Note: Stateless and stateful XRPC clients are implemented in xrpc_call.rs and at_client.rs ··· 70 session: AuthSession, 71 ) -> core::result::Result<(), SessionStoreError> { 72 self.0.set_session(session).await 73 } 74 75 /// Base URL of this client.
+15 -10
crates/jacquard/src/client/at_client.rs
··· 6 session::{SessionStore, SessionStoreError}, 7 types::{ 8 did::Did, 9 - xrpc::{CallOptions, Response, XrpcExt}, 10 }, 11 }; 12 use url::Url; 13 - 14 - use jacquard_common::types::xrpc::{XrpcRequest, build_http_request}; 15 16 use crate::client::{AtpSession, AuthSession, NSID_REFRESH_SESSION}; 17 18 /// Per-call overrides when sending via `AtClient`. 19 - #[derive(Debug, Default, Clone)] 20 pub struct SendOverrides<'a> { 21 pub did: Option<Did<'a>>, 22 /// Optional base URI override for this call. 23 pub base_uri: Option<Url>, ··· 27 pub auto_refresh: bool, 28 } 29 30 impl<'a> SendOverrides<'a> { 31 /// Construct default overrides (no base override, auto-refresh enabled). 32 pub fn new() -> Self { ··· 126 let did = s.did().clone().into_static(); 127 self.refresh_lock.lock().await.replace(did.clone()); 128 self.tokens.set(did, session).await 129 - } 130 - 131 - /// Clear the current session from the token store. 132 - pub async fn clear_session(&self) -> Result<(), SessionStoreError> { 133 - self.tokens.clear().await 134 } 135 136 /// Send an XRPC request using the client's base URL and default behavior. ··· 237 .auth(AuthorizationToken::Bearer( 238 refresh_tok.clone().into_static(), 239 )) 240 - .send(jacquard_api::com_atproto::server::refresh_session::RefreshSession) 241 .await?; 242 let refreshed = match refresh_resp.into_output() { 243 Ok(o) => AtpSession::from(o),
··· 6 session::{SessionStore, SessionStoreError}, 7 types::{ 8 did::Did, 9 + xrpc::{CallOptions, Response, XrpcExt, XrpcRequest, build_http_request}, 10 }, 11 }; 12 use url::Url; 13 14 use crate::client::{AtpSession, AuthSession, NSID_REFRESH_SESSION}; 15 16 /// Per-call overrides when sending via `AtClient`. 17 + #[derive(Debug, Clone)] 18 pub struct SendOverrides<'a> { 19 + /// Optional DID override for this call. 20 pub did: Option<Did<'a>>, 21 /// Optional base URI override for this call. 22 pub base_uri: Option<Url>, ··· 26 pub auto_refresh: bool, 27 } 28 29 + impl Default for SendOverrides<'_> { 30 + fn default() -> Self { 31 + Self { 32 + did: None, 33 + base_uri: None, 34 + options: CallOptions::default(), 35 + auto_refresh: true, 36 + } 37 + } 38 + } 39 + 40 impl<'a> SendOverrides<'a> { 41 /// Construct default overrides (no base override, auto-refresh enabled). 42 pub fn new() -> Self { ··· 136 let did = s.did().clone().into_static(); 137 self.refresh_lock.lock().await.replace(did.clone()); 138 self.tokens.set(did, session).await 139 } 140 141 /// Send an XRPC request using the client's base URL and default behavior. ··· 242 .auth(AuthorizationToken::Bearer( 243 refresh_tok.clone().into_static(), 244 )) 245 + .send(&jacquard_api::com_atproto::server::refresh_session::RefreshSession) 246 .await?; 247 let refreshed = match refresh_resp.into_output() { 248 Ok(o) => AtpSession::from(o),
+287 -74
crates/jacquard/src/client/token.rs
··· 1 - use crate::client::AtpSession; 2 - use async_trait::async_trait; 3 use jacquard_common::IntoStatic; 4 - use jacquard_common::session::{SessionStore, SessionStoreError}; 5 - use jacquard_common::types::string::{Did, Handle}; 6 use std::path::{Path, PathBuf}; 7 8 - /// File-backed token store using a JSON file. 9 - /// 10 - /// Example 11 - /// ```ignore 12 - /// use jacquard::client::{AtClient, FileTokenStore}; 13 - /// let base = url::Url::parse("https://bsky.social").unwrap(); 14 - /// let store = FileTokenStore::new("/tmp/jacquard-session.json"); 15 - /// let client = AtClient::new(reqwest::Client::new(), base, store); 16 - /// ``` 17 - #[derive(Clone, Debug)] 18 - pub struct FileTokenStore { 19 - path: PathBuf, 20 } 21 22 - impl FileTokenStore { 23 - /// Create a new file token store at the given path. 24 - pub fn new(path: impl AsRef<Path>) -> Self { 25 - Self { 26 - path: path.as_ref().to_path_buf(), 27 } 28 } 29 } 30 31 - #[derive(serde::Serialize, serde::Deserialize)] 32 - struct FileSession { 33 - access_jwt: String, 34 - refresh_jwt: String, 35 - did: String, 36 - handle: String, 37 } 38 39 - #[async_trait] 40 - impl SessionStore<Did<'static>, AtpSession> for FileTokenStore { 41 - async fn get(&self, key: &Did<'static>) -> Option<AtpSession> { 42 - let mut path = self.path.clone(); 43 - path.push(key.to_string()); 44 - let data = tokio::fs::read(&path).await.ok()?; 45 - let disk: FileSession = serde_json::from_slice(&data).ok()?; 46 - let did = Did::new_owned(disk.did).ok()?; 47 - let handle = Handle::new_owned(disk.handle).ok()?; 48 - Some(AtpSession { 49 - access_jwt: disk.access_jwt.into(), 50 - refresh_jwt: disk.refresh_jwt.into(), 51 - did: did.into_static(), 52 - handle: handle.into_static(), 53 - }) 54 } 55 56 - async fn set(&self, key: Did<'static>, session: AtpSession) -> Result<(), SessionStoreError> { 57 - let disk = FileSession { 58 - access_jwt: session.access_jwt.to_string(), 59 - refresh_jwt: session.refresh_jwt.to_string(), 60 - did: session.did.to_string(), 61 - handle: session.handle.to_string(), 62 - }; 63 - let buf = serde_json::to_vec_pretty(&disk).map_err(SessionStoreError::from)?; 64 - if let Some(parent) = self.path.parent() { 65 - tokio::fs::create_dir_all(parent) 66 - .await 67 - .map_err(SessionStoreError::from)?; 68 } 69 - let mut path = self.path.clone(); 70 - path.push(key.to_string()); 71 - let tmp = path.with_extension("tmp"); 72 - tokio::fs::write(&tmp, &buf) 73 - .await 74 - .map_err(SessionStoreError::from)?; 75 - tokio::fs::rename(&tmp, &path) 76 .await 77 - .map_err(SessionStoreError::from)?; 78 Ok(()) 79 } 80 81 - async fn del(&self, key: &Did<'static>) -> Result<(), SessionStoreError> { 82 - let mut path = self.path.clone(); 83 - path.push(key.to_string()); 84 - match tokio::fs::remove_file(&path).await { 85 - Ok(_) => Ok(()), 86 - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 87 - Err(e) => Err(SessionStoreError::from(e)), 88 } 89 } 90 91 - async fn clear(&self) -> Result<(), SessionStoreError> { 92 - match tokio::fs::remove_file(&self.path).await { 93 - Ok(_) => Ok(()), 94 - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 95 - Err(e) => Err(SessionStoreError::from(e)), 96 } 97 } 98 }
··· 1 use jacquard_common::IntoStatic; 2 + use jacquard_common::cowstr::ToCowStr; 3 + use jacquard_common::session::{FileTokenStore, SessionStore, SessionStoreError}; 4 + use jacquard_common::types::string::{Datetime, Did, Handle}; 5 + use jacquard_oauth::scopes::Scope; 6 + use jacquard_oauth::session::{AuthRequestData, ClientSessionData, DpopClientData, DpopReqData}; 7 + use jacquard_oauth::types::OAuthTokenType; 8 + use jose_jwk::Key; 9 + use serde::de::DeserializeOwned; 10 + use serde::{Deserialize, Serialize}; 11 + use serde_json::Value; 12 + use std::fmt::Display; 13 + use std::hash::Hash; 14 use std::path::{Path, PathBuf}; 15 + use url::Url; 16 17 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 18 + enum StoredSession { 19 + Atp(StoredAtSession), 20 + OAuth(OAuthSession), 21 + OAuthState(OAuthState), 22 + } 23 + 24 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 25 + struct StoredAtSession { 26 + access_jwt: String, 27 + refresh_jwt: String, 28 + did: String, 29 + pds: String, 30 + session_id: String, 31 + handle: String, 32 + } 33 + 34 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 35 + struct OAuthSession { 36 + account_did: String, 37 + session_id: String, 38 + 39 + // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info. 40 + host_url: Url, 41 + 42 + // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info. 43 + authserver_url: Url, 44 + 45 + // Full token endpoint 46 + authserver_token_endpoint: String, 47 + 48 + // Full revocation endpoint, if it exists 49 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 50 + authserver_revocation_endpoint: Option<String>, 51 + 52 + // The set of scopes approved for this session (returned in the initial token request) 53 + scopes: Vec<String>, 54 + 55 + pub dpop_key: Key, 56 + // Current auth server DPoP nonce 57 + pub dpop_authserver_nonce: String, 58 + // Current host ("resource server", eg PDS) DPoP nonce 59 + pub dpop_host_nonce: String, 60 + 61 + pub iss: String, 62 + pub sub: String, 63 + pub aud: String, 64 + pub scope: Option<String>, 65 + 66 + pub refresh_token: Option<String>, 67 + pub access_token: String, 68 + pub token_type: OAuthTokenType, 69 + 70 + pub expires_at: Option<Datetime>, 71 + } 72 + 73 + impl From<ClientSessionData<'_>> for OAuthSession { 74 + fn from(data: ClientSessionData<'_>) -> Self { 75 + OAuthSession { 76 + account_did: data.account_did.to_string(), 77 + session_id: data.session_id.to_string(), 78 + host_url: data.host_url, 79 + authserver_url: data.authserver_url, 80 + authserver_token_endpoint: data.authserver_token_endpoint.to_string(), 81 + authserver_revocation_endpoint: data 82 + .authserver_revocation_endpoint 83 + .map(|s| s.to_string()), 84 + scopes: data.scopes.into_iter().map(|s| s.to_string()).collect(), 85 + dpop_key: data.dpop_data.dpop_key, 86 + dpop_authserver_nonce: data.dpop_data.dpop_authserver_nonce.to_string(), 87 + dpop_host_nonce: data.dpop_data.dpop_host_nonce.to_string(), 88 + iss: data.token_set.iss.to_string(), 89 + sub: data.token_set.sub.to_string(), 90 + aud: data.token_set.aud.to_string(), 91 + scope: data.token_set.scope.map(|s| s.to_string()), 92 + refresh_token: data.token_set.refresh_token.map(|s| s.to_string()), 93 + access_token: data.token_set.access_token.to_string(), 94 + token_type: data.token_set.token_type, 95 + expires_at: data.token_set.expires_at, 96 + } 97 + } 98 } 99 100 + impl From<OAuthSession> for ClientSessionData<'_> { 101 + fn from(session: OAuthSession) -> Self { 102 + ClientSessionData { 103 + account_did: session.account_did.into(), 104 + session_id: session.session_id.to_cowstr(), 105 + host_url: session.host_url, 106 + authserver_url: session.authserver_url, 107 + authserver_token_endpoint: session.authserver_token_endpoint.to_cowstr(), 108 + authserver_revocation_endpoint: session 109 + .authserver_revocation_endpoint 110 + .map(|s| s.to_cowstr().into_static()), 111 + scopes: session 112 + .scopes 113 + .into_iter() 114 + .map(|s| Scope::parse(&s).unwrap().into_static()) 115 + .collect(), 116 + dpop_data: DpopClientData { 117 + dpop_key: session.dpop_key, 118 + dpop_authserver_nonce: session.dpop_authserver_nonce.to_cowstr(), 119 + dpop_host_nonce: session.dpop_host_nonce.to_cowstr(), 120 + }, 121 + token_set: jacquard_oauth::types::TokenSet { 122 + iss: session.iss.into(), 123 + sub: session.sub.into(), 124 + aud: session.aud.into(), 125 + scope: session.scope.map(|s| s.into()), 126 + refresh_token: session.refresh_token.map(|s| s.into()), 127 + access_token: session.access_token.into(), 128 + token_type: session.token_type, 129 + expires_at: session.expires_at, 130 + }, 131 } 132 + .into_static() 133 } 134 } 135 136 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 137 + pub struct OAuthState { 138 + // The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information. 139 + pub state: String, 140 + 141 + // URL of the auth server (eg, PDS or entryway) 142 + pub authserver_url: Url, 143 + 144 + // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response. 145 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 146 + pub account_did: Option<String>, 147 + 148 + // OAuth scope strings 149 + pub scopes: Vec<String>, 150 + 151 + // unique token in URI format, which will be used by the client in the auth flow redirect 152 + pub request_uri: String, 153 + 154 + // Full token endpoint URL 155 + pub authserver_token_endpoint: String, 156 + 157 + // Full revocation endpoint, if it exists 158 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 159 + pub authserver_revocation_endpoint: Option<String>, 160 + 161 + // The secret token/nonce which a code challenge was generated from 162 + pub pkce_verifier: String, 163 + 164 + pub dpop_key: Key, 165 + // Current auth server DPoP nonce 166 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 167 + pub dpop_authserver_nonce: Option<String>, 168 } 169 170 + impl From<AuthRequestData<'_>> for OAuthState { 171 + fn from(value: AuthRequestData) -> Self { 172 + OAuthState { 173 + authserver_url: value.authserver_url, 174 + account_did: value.account_did.map(|s| s.to_string()), 175 + scopes: value.scopes.into_iter().map(|s| s.to_string()).collect(), 176 + request_uri: value.request_uri.to_string(), 177 + authserver_token_endpoint: value.authserver_token_endpoint.to_string(), 178 + authserver_revocation_endpoint: value 179 + .authserver_revocation_endpoint 180 + .map(|s| s.to_string()), 181 + pkce_verifier: value.pkce_verifier.to_string(), 182 + dpop_key: value.dpop_data.dpop_key, 183 + dpop_authserver_nonce: value.dpop_data.dpop_authserver_nonce.map(|s| s.to_string()), 184 + state: value.state.to_string(), 185 + } 186 } 187 + } 188 189 + impl From<OAuthState> for AuthRequestData<'_> { 190 + fn from(value: OAuthState) -> Self { 191 + AuthRequestData { 192 + authserver_url: value.authserver_url, 193 + state: value.state.to_cowstr(), 194 + account_did: value.account_did.map(|s| Did::from(s).into_static()), 195 + authserver_revocation_endpoint: value 196 + .authserver_revocation_endpoint 197 + .map(|s| s.to_cowstr().into_static()), 198 + scopes: value 199 + .scopes 200 + .into_iter() 201 + .map(|s| Scope::parse(&s).unwrap().into_static()) 202 + .collect(), 203 + request_uri: value.request_uri.to_cowstr(), 204 + authserver_token_endpoint: value.authserver_token_endpoint.to_cowstr(), 205 + pkce_verifier: value.pkce_verifier.to_cowstr(), 206 + dpop_data: DpopReqData { 207 + dpop_key: value.dpop_key, 208 + dpop_authserver_nonce: value 209 + .dpop_authserver_nonce 210 + .map(|s| s.to_cowstr().into_static()), 211 + }, 212 } 213 + .into_static() 214 + } 215 + } 216 + 217 + pub struct FileAuthStore(FileTokenStore); 218 + 219 + #[async_trait::async_trait] 220 + impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore { 221 + async fn get_session( 222 + &self, 223 + did: &Did<'_>, 224 + session_id: &str, 225 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 226 + let key = format!("{}_{}", did, session_id); 227 + if let StoredSession::OAuth(session) = self 228 + .0 229 + .get(&key) 230 .await 231 + .ok_or(SessionStoreError::Other("not found".into()))? 232 + { 233 + Ok(Some(session.into())) 234 + } else { 235 + Ok(None) 236 + } 237 + } 238 + 239 + async fn upsert_session( 240 + &self, 241 + session: ClientSessionData<'_>, 242 + ) -> Result<(), SessionStoreError> { 243 + let key = format!("{}_{}", session.account_did, session.session_id); 244 + self.0 245 + .set(key, StoredSession::OAuth(session.into())) 246 + .await?; 247 Ok(()) 248 } 249 250 + async fn delete_session( 251 + &self, 252 + did: &Did<'_>, 253 + session_id: &str, 254 + ) -> Result<(), SessionStoreError> { 255 + let key = format!("{}_{}", did, session_id); 256 + let file = std::fs::read_to_string(&self.0.path)?; 257 + let mut store: Value = serde_json::from_str(&file)?; 258 + let key_string = key.to_string(); 259 + if let Some(store) = store.as_object_mut() { 260 + store.remove(&key_string); 261 + 262 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 263 + Ok(()) 264 + } else { 265 + Err(SessionStoreError::Other("invalid store".into())) 266 } 267 } 268 269 + async fn get_auth_req_info( 270 + &self, 271 + state: &str, 272 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 273 + let key = format!("authreq_{}", state); 274 + if let StoredSession::OAuthState(auth_req) = self 275 + .0 276 + .get(&key) 277 + .await 278 + .ok_or(SessionStoreError::Other("not found".into()))? 279 + { 280 + Ok(Some(auth_req.into())) 281 + } else { 282 + Ok(None) 283 + } 284 + } 285 + 286 + async fn save_auth_req_info( 287 + &self, 288 + auth_req_info: &AuthRequestData<'_>, 289 + ) -> Result<(), SessionStoreError> { 290 + let key = format!("authreq_{}", auth_req_info.state); 291 + self.0 292 + .set(key, StoredSession::OAuthState(auth_req_info.clone().into())) 293 + .await?; 294 + Ok(()) 295 + } 296 + 297 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 298 + let key = format!("authreq_{}", state); 299 + let file = std::fs::read_to_string(&self.0.path)?; 300 + let mut store: Value = serde_json::from_str(&file)?; 301 + let key_string = key.to_string(); 302 + if let Some(store) = store.as_object_mut() { 303 + store.remove(&key_string); 304 + 305 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 306 + Ok(()) 307 + } else { 308 + Err(SessionStoreError::Other("invalid store".into())) 309 } 310 } 311 }
+1 -1
crates/jacquard/src/main.rs
··· 2 use jacquard::CowStr; 3 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 use jacquard::api::com_atproto::server::create_session::CreateSession; 5 - use jacquard::client::{AtpSession, AuthSession, BasicClient}; 6 use jacquard::identity::resolver::IdentityResolver; 7 use jacquard::identity::slingshot_resolver_default; 8 use jacquard::types::string::Handle;
··· 2 use jacquard::CowStr; 3 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 use jacquard::api::com_atproto::server::create_session::CreateSession; 5 + use jacquard::client::{AtpSession, BasicClient}; 6 use jacquard::identity::resolver::IdentityResolver; 7 use jacquard::identity::slingshot_resolver_default; 8 use jacquard::types::string::Handle;