···33//! This module provides HTTP and XRPC client traits along with an authenticated
44//! client implementation that manages session tokens.
5566+/// Stateful session client for app‑password auth with auto‑refresh.
67pub mod credential_session;
88+/// Token storage and on‑disk formats shared across app‑password and OAuth.
79pub mod token;
810911use core::future::Future;
···7274 }
7375}
74767575-/// A unified indicator for the type of authenticated session.
7777+/// Identifies the active authentication mode for an agent/session.
7678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7779pub enum AgentKind {
7880 /// App password (Bearer) session
···8284}
83858486/// Common interface for stateful sessions used by the Agent wrapper.
8787+///
8888+/// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP).
8589pub trait AgentSession: XrpcClient + HttpClient + Send + Sync {
8690 /// Identify the kind of session.
8791 fn session_kind(&self) -> AgentKind;
···188192 }
189193}
190194191191-/// Thin wrapper that erases the concrete session type while preserving type-safety.
195195+/// Thin wrapper over a stateful session providing a uniform `XrpcClient`.
192196pub struct Agent<A: AgentSession> {
193197 inner: A,
194198}
···214218 self.inner.endpoint().await
215219 }
216220217217- /// Override call options.
221221+ /// Override call options for subsequent requests.
218222 pub async fn set_options(&self, opts: CallOptions<'_>) {
219223 self.inner.set_options(opts).await
220224 }
+41-4
crates/jacquard/src/client/credential_session.rs
···1818use jacquard_identity::resolver::IdentityResolver;
1919use std::any::Any;
20202121+/// Storage key for app‑password sessions: `(account DID, session id)`.
2122pub type SessionKey = (Did<'static>, CowStr<'static>);
22232424+/// Stateful client for app‑password based sessions.
2525+///
2626+/// - Persists sessions via a pluggable `SessionStore`.
2727+/// - Automatically refreshes on token expiry.
2828+/// - Tracks a base endpoint, defaulting to the public appview until login/restore.
2329pub struct CredentialSession<S, T>
2430where
2531 S: SessionStore<SessionKey, AtpSession>,
2632{
2733 store: Arc<S>,
2834 client: Arc<T>,
3535+ /// Default call options applied to each request (auth/headers/labelers).
2936 pub options: RwLock<CallOptions<'static>>,
3737+ /// Active session key, if any.
3038 pub key: RwLock<Option<SessionKey>>,
3939+ /// Current base endpoint (PDS); defaults to public appview when unset.
3140 pub endpoint: RwLock<Option<Url>>,
3241}
3342···3544where
3645 S: SessionStore<SessionKey, AtpSession>,
3746{
4747+ /// Create a new credential session using the given store and client.
3848 pub fn new(store: Arc<S>, client: Arc<T>) -> Self {
3949 Self {
4050 store,
···5060where
5161 S: SessionStore<SessionKey, AtpSession>,
5262{
6363+ /// Return a copy configured with the provided default call options.
5364 pub fn with_options(self, options: CallOptions<'_>) -> Self {
5465 Self {
5566 client: self.client,
···6071 }
6172 }
62737474+ /// Replace default call options.
6375 pub async fn set_options(&self, options: CallOptions<'_>) {
6476 *self.options.write().await = options.into_static();
6577 }
66787979+ /// Get the active session key (account DID and session id), if any.
6780 pub async fn session_info(&self) -> Option<SessionKey> {
6881 self.key.read().await.clone()
6982 }
70838484+ /// Current base endpoint. Defaults to the public appview when unset.
7185 pub async fn endpoint(&self) -> Url {
7286 self.endpoint.read().await.clone().unwrap_or(
7387 Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
7488 )
7589 }
76909191+ /// Override the current base endpoint.
7792 pub async fn set_endpoint(&self, endpoint: Url) {
7893 *self.endpoint.write().await = Some(endpoint);
7994 }
80959696+ /// Current access token (Bearer), if logged in.
8197 pub async fn access_token(&self) -> Option<AuthorizationToken<'_>> {
8298 let key = self.key.read().await.clone()?;
8399 let session = self.store.get(&key).await;
84100 session.map(|session| AuthorizationToken::Bearer(session.access_jwt))
85101 }
86102103103+ /// Current refresh token (Bearer), if logged in.
87104 pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> {
88105 let key = self.key.read().await.clone()?;
89106 let session = self.store.get(&key).await;
···96113 S: SessionStore<SessionKey, AtpSession>,
97114 T: HttpClient,
98115{
116116+ /// Refresh the active session by calling `com.atproto.server.refreshSession`.
99117 pub async fn refresh(&self) -> Result<AuthorizationToken<'_>, ClientError> {
100118 let key = self.key.read().await.clone().ok_or(ClientError::Auth(
101119 jacquard_common::error::AuthError::NotAuthenticated,
···134152 ///
135153 /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL.
136154 /// - `session_id`: optional session label; defaults to "session".
155155+ /// - Persists and activates the session, and updates the base endpoint to the user's PDS.
137156 pub async fn login(
138157 &self,
139158 identifier: CowStr<'_>,
···298317 Ok(())
299318 }
300319301301- /// Switch to a different stored session (and refresh endpoint from DID).
320320+ /// Switch to a different stored session (and refresh endpoint/PDS).
302321 pub async fn switch_session(
303322 &self,
304323 did: Did<'_>,
···380399 T: HttpClient + XrpcExt + Send + Sync + 'static,
381400{
382401 fn base_uri(&self) -> Url {
383383- self.endpoint.blocking_read().clone().unwrap_or(
384384- Url::parse("https://public.bsky.app").expect("public appview should be valid url"),
385385- )
402402+ // base_uri is a synchronous trait method; avoid `.await` here.
403403+ // Under Tokio, use `block_in_place` to make a blocking RwLock read safe.
404404+ if tokio::runtime::Handle::try_current().is_ok() {
405405+ tokio::task::block_in_place(|| {
406406+ self.endpoint
407407+ .blocking_read()
408408+ .clone()
409409+ .unwrap_or(
410410+ Url::parse("https://public.bsky.app")
411411+ .expect("public appview should be valid url"),
412412+ )
413413+ })
414414+ } else {
415415+ self.endpoint
416416+ .blocking_read()
417417+ .clone()
418418+ .unwrap_or(
419419+ Url::parse("https://public.bsky.app")
420420+ .expect("public appview should be valid url"),
421421+ )
422422+ }
386423 }
387424 async fn send<R: jacquard_common::types::xrpc::XrpcRequest + Send>(
388425 self,
+95-25
crates/jacquard/src/client/token.rs
···1010use serde_json::Value;
1111use url::Url;
12121313+/// On-disk session records for app-password and OAuth flows, sharing a single JSON map.
1314#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1415pub enum StoredSession {
1616+ /// App-password session
1517 Atp(StoredAtSession),
1818+ /// OAuth client session
1619 OAuth(OAuthSession),
2020+ /// OAuth authorization request state
1721 OAuthState(OAuthState),
1822}
19232424+/// Minimal persisted representation of an app‑password session.
2025#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2126pub struct StoredAtSession {
2727+ /// Access token (JWT)
2228 access_jwt: String,
2929+ /// Refresh token (JWT)
2330 refresh_jwt: String,
3131+ /// Account DID
2432 did: String,
3333+ /// Optional PDS endpoint for faster resume
2534 #[serde(skip_serializing_if = "std::option::Option::is_none")]
2635 pds: Option<String>,
3636+ /// Session id label (e.g., "session")
2737 session_id: String,
3838+ /// Last known handle
2839 handle: String,
2940}
30414242+/// Persisted OAuth client session (on-disk format).
3143#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
3244pub struct OAuthSession {
4545+ /// Account DID
3346 account_did: String,
4747+ /// Client-generated session id (usually auth `state`)
3448 session_id: String,
35493636- // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
5050+ /// Base URL of the resource server (PDS)
3751 host_url: Url,
38523939- // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
5353+ /// Base URL of the authorization server (PDS or entryway)
4054 authserver_url: Url,
41554242- // Full token endpoint
5656+ /// Full token endpoint URL
4357 authserver_token_endpoint: String,
44584545- // Full revocation endpoint, if it exists
5959+ /// Full revocation endpoint URL, if available
4660 #[serde(skip_serializing_if = "std::option::Option::is_none")]
4761 authserver_revocation_endpoint: Option<String>,
48624949- // The set of scopes approved for this session (returned in the initial token request)
6363+ /// Granted scopes
5064 scopes: Vec<String>,
51656666+ /// Client DPoP key material
5267 pub dpop_key: Key,
5353- // Current auth server DPoP nonce
6868+ /// Current auth server DPoP nonce
5469 pub dpop_authserver_nonce: String,
5555- // Current host ("resource server", eg PDS) DPoP nonce
7070+ /// Current resource server (PDS) DPoP nonce
5671 pub dpop_host_nonce: String,
57727373+ /// Token response issuer
5874 pub iss: String,
7575+ /// Token subject (DID)
5976 pub sub: String,
7777+ /// Token audience (verified PDS URL)
6078 pub aud: String,
7979+ /// Token scopes (raw) if provided
6180 pub scope: Option<String>,
62818282+ /// Refresh token
6383 pub refresh_token: Option<String>,
8484+ /// Access token
6485 pub access_token: String,
8686+ /// Token type (e.g., DPoP)
6587 pub token_type: OAuthTokenType,
66888989+ /// Expiration timestamp
6790 pub expires_at: Option<Datetime>,
6891}
6992···130153 }
131154}
132155156156+/// Persisted OAuth authorization request state.
133157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134158pub struct OAuthState {
135135- // The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
159159+ /// Random identifier generated for the authorization flow (`state`)
136160 pub state: String,
137161138138- // URL of the auth server (eg, PDS or entryway)
162162+ /// Base URL of the authorization server (PDS or entryway)
139163 pub authserver_url: Url,
140164141141- // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
165165+ /// Optional pre-known account DID
142166 #[serde(skip_serializing_if = "std::option::Option::is_none")]
143167 pub account_did: Option<String>,
144168145145- // OAuth scope strings
169169+ /// Requested scopes
146170 pub scopes: Vec<String>,
147171148148- // unique token in URI format, which will be used by the client in the auth flow redirect
172172+ /// Request URI for the authorization step
149173 pub request_uri: String,
150174151151- // Full token endpoint URL
175175+ /// Full token endpoint URL
152176 pub authserver_token_endpoint: String,
153177154154- // Full revocation endpoint, if it exists
178178+ /// Full revocation endpoint URL, if available
155179 #[serde(skip_serializing_if = "std::option::Option::is_none")]
156180 pub authserver_revocation_endpoint: Option<String>,
157181158158- // The secret token/nonce which a code challenge was generated from
182182+ /// PKCE verifier
159183 pub pkce_verifier: String,
160184185185+ /// Client DPoP key material
161186 pub dpop_key: Key,
162162- // Current auth server DPoP nonce
187187+ /// Auth server DPoP nonce at PAR time
163188 #[serde(skip_serializing_if = "std::option::Option::is_none")]
164189 pub dpop_authserver_nonce: Option<String>,
165190}
···211236 }
212237}
213238239239+/// Convenience wrapper over `FileTokenStore` offering unified storage across auth modes.
214240pub struct FileAuthStore(FileTokenStore);
215241216242impl FileAuthStore {
···326352 let mut store: Value = serde_json::from_str(&file)?;
327353 if let Some(map) = store.as_object_mut() {
328354 if let Some(value) = map.get_mut(&key_str) {
329329- if let Some(obj) = value.as_object_mut() {
330330- obj.insert(
331331- "pds".to_string(),
332332- serde_json::Value::String(pds.to_string()),
333333- );
334334- std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
335335- return Ok(());
355355+ if let Some(outer) = value.as_object_mut() {
356356+ if let Some(inner) = outer.get_mut("Atp").and_then(|v| v.as_object_mut()) {
357357+ inner.insert(
358358+ "pds".to_string(),
359359+ serde_json::Value::String(pds.to_string()),
360360+ );
361361+ std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
362362+ return Ok(());
363363+ }
336364 }
337365 }
338366 }
···349377 let store: Value = serde_json::from_str(&file)?;
350378 if let Some(value) = store.get(&key_str) {
351379 if let Some(obj) = value.as_object() {
352352- if let Some(serde_json::Value::String(pds)) = obj.get("pds") {
353353- return Ok(Url::parse(pds).ok());
380380+ if let Some(serde_json::Value::Object(inner)) = obj.get("Atp") {
381381+ if let Some(serde_json::Value::String(pds)) = inner.get("pds") {
382382+ return Ok(Url::parse(pds).ok());
383383+ }
354384 }
355385 }
356386 }
···418448 }
419449 }
420450}
451451+452452+#[cfg(test)]
453453+mod tests {
454454+ use super::*;
455455+ use crate::client::credential_session::SessionKey;
456456+ use crate::client::AtpSession;
457457+ use jacquard_common::types::string::{Did, Handle};
458458+ use std::fs;
459459+ use std::path::PathBuf;
460460+461461+ fn temp_file() -> PathBuf {
462462+ let mut p = std::env::temp_dir();
463463+ p.push(format!("jacquard-test-{}.json", std::process::id()));
464464+ p
465465+ }
466466+467467+ #[tokio::test]
468468+ async fn file_auth_store_roundtrip_atp() {
469469+ let path = temp_file();
470470+ // initialize empty store file
471471+ fs::write(&path, "{}").unwrap();
472472+ let store = FileAuthStore::new(&path);
473473+ let session = AtpSession {
474474+ access_jwt: "a".into(),
475475+ refresh_jwt: "r".into(),
476476+ did: Did::new_static("did:plc:alice").unwrap(),
477477+ handle: Handle::new_static("alice.bsky.social").unwrap(),
478478+ };
479479+ let key: SessionKey = (session.did.clone(), "session".into());
480480+ jacquard_common::session::SessionStore::set(&store, key.clone(), session.clone())
481481+ .await
482482+ .unwrap();
483483+ let restored = jacquard_common::session::SessionStore::get(&store, &key)
484484+ .await
485485+ .unwrap();
486486+ assert_eq!(restored.access_jwt.as_ref(), "a");
487487+ // clean up
488488+ let _ = fs::remove_file(&path);
489489+ }
490490+}