A better Rust ATProto crate
1

Configure Feed

Select the types of activity you want to include in your feed.

auth resume improvements and a bunch of cleanup of associated types

author nonbinary.computer date (Jun 7, 2026, 8:21 PM -0400) commit f88f8852 parent 9a664d6f change-id sntmrpvl
+1635 -353
+2 -2
crates/jacquard-axum/tests/service_auth_tests.rs
··· 15 15 }; 16 16 use jacquard_common::{ 17 17 bos::BosStr, 18 - deps::smol_str::SmolStr, 18 + deps::smol_str::{SmolStr, format_smolstr}, 19 19 service_auth::JwtHeader, 20 20 types::{ 21 21 did::Did, ··· 114 114 id: Did::new_owned(did).unwrap(), 115 115 also_known_as: None, 116 116 verification_method: Some(vec![VerificationMethod { 117 - id: SmolStr::from(format!("{}#atproto", did)), 117 + id: format_smolstr!("{}#atproto", did), 118 118 r#type: SmolStr::new_static("Multikey"), 119 119 controller: Some(SmolStr::from(did)), 120 120 public_key_multibase: Some(SmolStr::from(multibase_key)),
+255 -30
crates/jacquard-common/src/session.rs
··· 1 1 //! Generic session storage traits and utilities. 2 2 3 3 use alloc::boxed::Box; 4 - #[cfg(feature = "std")] 5 - use alloc::string::ToString; 4 + use alloc::collections::BTreeMap; 5 + use alloc::string::String; 6 6 use alloc::sync::Arc; 7 + use alloc::vec::Vec; 7 8 use core::error::Error as StdError; 8 - #[cfg(feature = "std")] 9 - use core::fmt::Display; 9 + use core::fmt; 10 10 use core::future::Future; 11 11 use core::hash::Hash; 12 - use hashbrown::HashMap; 13 12 #[cfg(feature = "std")] 14 13 use miette::Diagnostic; 15 - use serde::Serialize; 16 - use serde::de::DeserializeOwned; 14 + use serde::{Deserialize, Serialize}; 17 15 use serde_json::Value; 16 + use smol_str::SmolStr; 17 + 18 + use crate::bos::{BosStr, DefaultStr}; 19 + use crate::types::{did::Did, handle::Handle}; 18 20 19 21 #[cfg(feature = "std")] 20 22 use std::path::{Path, PathBuf}; ··· 45 47 Other(#[from] Box<dyn StdError + Send + Sync>), 46 48 } 47 49 50 + /// Shared storage key for app-password and OAuth sessions. 51 + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 52 + pub struct SessionKey { 53 + /// Account DID. 54 + pub did: Did, 55 + /// Store-local session identifier. 56 + pub session_id: SmolStr, 57 + } 58 + 59 + impl SessionKey { 60 + /// Create a new session key. 61 + pub fn new(did: Did, session_id: impl Into<SmolStr>) -> Self { 62 + Self { 63 + did, 64 + session_id: session_id.into(), 65 + } 66 + } 67 + 68 + /// Borrow the account DID. 69 + pub fn did(&self) -> Did<&str> { 70 + self.did.borrow() 71 + } 72 + 73 + /// Borrow the session identifier. 74 + pub fn session_id(&self) -> &str { 75 + self.session_id.as_str() 76 + } 77 + } 78 + 79 + impl fmt::Display for SessionKey { 80 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 81 + write!(f, "{}/{}", self.did, self.session_id) 82 + } 83 + } 84 + 85 + impl From<(Did, SmolStr)> for SessionKey { 86 + fn from((did, session_id): (Did, SmolStr)) -> Self { 87 + Self { did, session_id } 88 + } 89 + } 90 + 91 + impl From<SessionKey> for (Did, SmolStr) { 92 + fn from(key: SessionKey) -> Self { 93 + (key.did, key.session_id) 94 + } 95 + } 96 + 97 + /// Resolver-free hint for choosing a stored session. 98 + /// 99 + /// Matching in `jacquard-common` is intentionally key-only and does not perform identity 100 + /// resolution. [`SessionHint::Handle`] cannot be matched from [`SessionKey`] values alone and 101 + /// returns no match in [`match_session_key`]; higher-level stores may add handle-aware matching 102 + /// when they have typed records containing handle metadata. 103 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 104 + pub enum SessionHint<S: BosStr = DefaultStr> { 105 + /// Use any available session. 106 + Any, 107 + /// Use the first session for the given DID. 108 + Did(Did<S>), 109 + /// Use a session for the given handle, if a higher-level matcher can resolve it. 110 + Handle(Handle<S>), 111 + /// Use this exact key. 112 + Key(SessionKey), 113 + /// Login/start-auth identifier that is not necessarily session-addressable. 114 + /// 115 + /// Examples include an email address, explicit PDS/entryway URL, or 116 + /// application-specific login input. Default resolver-free selectors do not 117 + /// match this as an existing session. 118 + Identifier(S), 119 + } 120 + 121 + /// Match a session key using only resolver-free key data. 122 + pub fn match_session_key<I>(hint: &SessionHint, keys: I) -> Option<SessionKey> 123 + where 124 + I: IntoIterator<Item = SessionKey>, 125 + { 126 + match hint { 127 + SessionHint::Any => keys.into_iter().next(), 128 + SessionHint::Did(did) => keys.into_iter().find(|key| key.did == *did), 129 + SessionHint::Handle(_) | SessionHint::Identifier(_) => None, 130 + SessionHint::Key(target) => keys.into_iter().find(|key| key == target), 131 + } 132 + } 133 + 134 + /// Selects a session from a hint, optionally returning richer implementation-specific data. 135 + /// 136 + /// This trait is intentionally separate from [`SessionStore`]. Simple implementations may select 137 + /// by enumerating store keys and filtering, while database-backed or otherwise indexed 138 + /// implementations can resolve [`SessionHint::Key`] or [`SessionHint::Did`] without a full scan. 139 + /// Higher-level crates can also implement selectors that resolve [`SessionHint::Handle`] using an 140 + /// identity resolver and return metadata such as cached endpoints alongside the selected key. 141 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 142 + pub trait SessionSelector<M>: Send + Sync { 143 + /// Error returned by this selector. 144 + type Error; 145 + 146 + /// Select a matching session, if one exists. 147 + fn select_session( 148 + &self, 149 + hint: &SessionHint, 150 + ) -> impl Future<Output = Result<Option<M>, Self::Error>>; 151 + } 152 + 48 153 /// Pluggable storage for arbitrary session records. 49 154 #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 50 155 pub trait SessionStore<K, T>: Send + Sync ··· 58 163 fn set(&self, key: K, session: T) -> impl Future<Output = Result<(), SessionStoreError>>; 59 164 /// Delete the given session. 60 165 fn del(&self, key: &K) -> impl Future<Output = Result<(), SessionStoreError>>; 166 + /// List known session keys when the backend supports enumeration. 167 + fn list_keys(&self) -> impl Future<Output = Result<Vec<K>, SessionStoreError>> 168 + where 169 + K: Clone, 170 + { 171 + async { Ok(Vec::new()) } 172 + } 61 173 } 62 174 63 175 /// In-memory session store suitable for short-lived sessions and tests. 64 176 #[derive(Clone)] 65 - pub struct MemorySessionStore<K, T>(Arc<RwLock<HashMap<K, T>>>); 177 + pub struct MemorySessionStore<K, T>(Arc<RwLock<BTreeMap<K, T>>>); 66 178 67 179 impl<K, T> Default for MemorySessionStore<K, T> { 68 180 fn default() -> Self { 69 - Self(Arc::new(RwLock::new(HashMap::new()))) 181 + Self(Arc::new(RwLock::new(BTreeMap::new()))) 70 182 } 71 183 } 72 184 73 185 impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T> 74 186 where 75 - K: Eq + Hash + Send + Sync, 187 + K: Eq + Hash + Send + Sync + Ord, 76 188 T: Clone + Send + Sync, 77 189 { 78 190 async fn get(&self, key: &K) -> Option<T> { ··· 86 198 self.0.write().await.remove(key); 87 199 Ok(()) 88 200 } 201 + 202 + async fn list_keys(&self) -> Result<Vec<K>, SessionStoreError> 203 + where 204 + K: Clone, 205 + { 206 + Ok(self.0.read().await.keys().cloned().collect()) 207 + } 208 + } 209 + 210 + impl<T> SessionSelector<SessionKey> for MemorySessionStore<SessionKey, T> 211 + where 212 + T: Clone + Send + Sync, 213 + { 214 + type Error = SessionStoreError; 215 + 216 + async fn select_session(&self, hint: &SessionHint) -> Result<Option<SessionKey>, Self::Error> { 217 + Ok(match_session_key(hint, self.list_keys().await?)) 218 + } 89 219 } 90 220 91 221 /// File-backed token store using a JSON file. ··· 149 279 } 150 280 151 281 #[cfg(feature = "std")] 152 - impl<K: Eq + Hash + Display + Send + Sync, T: Clone + Serialize + DeserializeOwned + Send + Sync> 153 - SessionStore<K, T> for FileTokenStore 154 - { 155 - /// Get the current session if present. 156 - async fn get(&self, key: &K) -> Option<T> { 157 - let file = std::fs::read_to_string(&self.path).ok()?; 158 - let store: Value = serde_json::from_str(&file).ok()?; 282 + impl FileTokenStore { 283 + /// Read a JSON value by string key. 284 + pub fn get_value(&self, key: &str) -> Result<Option<Value>, SessionStoreError> { 285 + let file = std::fs::read_to_string(&self.path)?; 286 + let store: Value = serde_json::from_str(&file)?; 287 + Ok(store.get(key).cloned()) 288 + } 159 289 160 - let session = store.get(key.to_string())?; 161 - serde_json::from_value(session.clone()).ok() 162 - } 163 - /// Persist the given session. 164 - async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> { 290 + /// Insert or replace a JSON value by string key. 291 + pub fn set_value(&self, key: impl Into<String>, value: Value) -> Result<(), SessionStoreError> { 165 292 let file = std::fs::read_to_string(&self.path)?; 166 293 let mut store: Value = serde_json::from_str(&file)?; 167 - let key_string = key.to_string(); 168 294 if let Some(store) = store.as_object_mut() { 169 - store.insert(key_string, serde_json::to_value(session.clone())?); 170 - 295 + store.insert(key.into(), value); 171 296 std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?; 172 297 Ok(()) 173 298 } else { 174 299 Err(SessionStoreError::Other("invalid store".into())) 175 300 } 176 301 } 177 - /// Delete the given session. 178 - async fn del(&self, key: &K) -> Result<(), SessionStoreError> { 302 + 303 + /// Remove a JSON value by string key. 304 + pub fn remove_value(&self, key: &str) -> Result<(), SessionStoreError> { 179 305 let file = std::fs::read_to_string(&self.path)?; 180 306 let mut store: Value = serde_json::from_str(&file)?; 181 - let key_string = key.to_string(); 182 307 if let Some(store) = store.as_object_mut() { 183 - store.remove(&key_string); 184 - 308 + store.remove(key); 185 309 std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?; 186 310 Ok(()) 187 311 } else { 188 312 Err(SessionStoreError::Other("invalid store".into())) 189 313 } 314 + } 315 + 316 + /// Return all JSON object entries in the store. 317 + pub fn entries(&self) -> Result<Vec<(String, Value)>, SessionStoreError> { 318 + let file = std::fs::read_to_string(&self.path)?; 319 + let store: Value = serde_json::from_str(&file)?; 320 + if let Some(store) = store.as_object() { 321 + Ok(store 322 + .iter() 323 + .map(|(key, value)| (key.clone(), value.clone())) 324 + .collect()) 325 + } else { 326 + Err(SessionStoreError::Other("invalid store".into())) 327 + } 328 + } 329 + } 330 + 331 + #[cfg(test)] 332 + mod tests { 333 + use super::*; 334 + use alloc::string::ToString; 335 + 336 + #[test] 337 + fn session_key_display_uses_slash_separator() { 338 + let did = Did::new_static("did:plc:alice").unwrap(); 339 + let key = SessionKey::new(did, "session_1"); 340 + assert_eq!(key.to_string(), "did:plc:alice/session_1"); 341 + } 342 + 343 + #[tokio::test] 344 + async fn memory_store_lists_keys() { 345 + let store = MemorySessionStore::<SessionKey, String>::default(); 346 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "session"); 347 + store.set(key.clone(), "value".to_string()).await.unwrap(); 348 + assert_eq!(store.list_keys().await.unwrap(), vec![key]); 349 + } 350 + 351 + struct EmptyStore; 352 + 353 + impl SessionStore<SessionKey, String> for EmptyStore { 354 + async fn get(&self, _key: &SessionKey) -> Option<String> { 355 + None 356 + } 357 + 358 + async fn set(&self, _key: SessionKey, _session: String) -> Result<(), SessionStoreError> { 359 + Ok(()) 360 + } 361 + 362 + async fn del(&self, _key: &SessionKey) -> Result<(), SessionStoreError> { 363 + Ok(()) 364 + } 365 + } 366 + 367 + #[tokio::test] 368 + async fn default_list_keys_is_empty() { 369 + assert!(EmptyStore.list_keys().await.unwrap().is_empty()); 370 + } 371 + 372 + #[test] 373 + fn match_session_key_is_resolver_free() { 374 + let alice = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "a"); 375 + let bob = SessionKey::new(Did::new_static("did:plc:bob").unwrap(), "b"); 376 + let keys = vec![alice.clone(), bob.clone()]; 377 + 378 + assert_eq!( 379 + match_session_key(&SessionHint::Any, keys.clone()), 380 + Some(alice.clone()) 381 + ); 382 + assert_eq!( 383 + match_session_key(&SessionHint::Did(bob.did.clone()), keys.clone()), 384 + Some(bob.clone()) 385 + ); 386 + assert_eq!( 387 + match_session_key(&SessionHint::Key(bob.clone()), keys.clone()), 388 + Some(bob.clone()) 389 + ); 390 + assert_eq!( 391 + match_session_key( 392 + &SessionHint::Key(SessionKey::new( 393 + Did::new_static("did:plc:carol").unwrap(), 394 + "c", 395 + )), 396 + keys.clone(), 397 + ), 398 + None 399 + ); 400 + assert_eq!(match_session_key(&SessionHint::Any, Vec::new()), None); 401 + assert_eq!( 402 + match_session_key( 403 + &SessionHint::Handle(Handle::new_static("alice.example.com").unwrap()), 404 + keys.clone(), 405 + ), 406 + None 407 + ); 408 + assert_eq!( 409 + match_session_key( 410 + &SessionHint::Identifier(SmolStr::new("alice@example.com")), 411 + keys 412 + ), 413 + None 414 + ); 190 415 } 191 416 }
+2 -2
crates/jacquard-oauth/src/atproto.rs
··· 8 8 use jacquard_common::deps::fluent_uri::Uri; 9 9 use jacquard_common::{BosStr, IntoStatic}; 10 10 use serde::{Deserialize, Serialize}; 11 - use smol_str::SmolStr; 11 + use smol_str::{SmolStr, ToSmolStr}; 12 12 use thiserror::Error; 13 13 14 14 /// Errors that can occur when building AT Protocol OAuth client metadata. ··· 248 248 } 249 249 let redir_str = redirect_uris.as_ref().map(|uris| { 250 250 uris.iter() 251 - .map(|u| SmolStr::from(u.as_str().trim_end_matches("/"))) 251 + .map(|u| u.as_str().trim_end_matches("/").to_smolstr()) 252 252 .collect() 253 253 }); 254 254 let query = serde_html_form::to_string(Parameters {
+337 -12
crates/jacquard-oauth/src/authstore.rs
··· 4 4 use dashmap::DashMap; 5 5 use jacquard_common::{ 6 6 bos::BosStr, 7 - session::{SessionStore, SessionStoreError}, 7 + session::{SessionHint, SessionKey, SessionSelector, SessionStore, SessionStoreError}, 8 8 types::did::Did, 9 9 }; 10 + use jacquard_identity::resolver::IdentityResolver; 10 11 use smol_str::{SmolStr, format_smolstr}; 11 12 12 13 use crate::session::{AuthRequestData, ClientSessionData}; 13 14 15 + /// OAuth session lookup result with the matched key and session data. 16 + #[derive(Clone, Debug, PartialEq, Eq)] 17 + pub struct OAuthSessionMatch { 18 + /// Matched session key. 19 + pub key: SessionKey, 20 + /// Stored OAuth client session data for the matched key. 21 + pub session: ClientSessionData, 22 + } 23 + 24 + /// Resolver-backed OAuth session selector. 25 + /// 26 + /// This adapter keeps selection pluggable: callers can depend on [`SessionSelector`] while stores 27 + /// with better indexing can provide their own selector implementation. 28 + pub struct OAuthSessionSelector<'a, S, R> { 29 + store: &'a S, 30 + resolver: &'a R, 31 + } 32 + 33 + impl<'a, S, R> OAuthSessionSelector<'a, S, R> { 34 + /// Create a selector over an OAuth auth store and identity resolver. 35 + pub fn new(store: &'a S, resolver: &'a R) -> Self { 36 + Self { store, resolver } 37 + } 38 + } 39 + 40 + impl<S, R> SessionSelector<OAuthSessionMatch> for OAuthSessionSelector<'_, S, R> 41 + where 42 + S: ClientAuthStore + SessionSelector<OAuthSessionMatch, Error = SessionStoreError> + Sync, 43 + R: IdentityResolver + Sync, 44 + { 45 + type Error = SessionStoreError; 46 + 47 + async fn select_session( 48 + &self, 49 + hint: &SessionHint, 50 + ) -> Result<Option<OAuthSessionMatch>, Self::Error> { 51 + if let Some(matched) = self.store.select_session(hint).await? { 52 + return Ok(Some(matched)); 53 + } 54 + 55 + let SessionHint::Handle(handle) = hint else { 56 + return Ok(None); 57 + }; 58 + 59 + let did = self 60 + .resolver 61 + .resolve_handle(handle) 62 + .await 63 + .map_err(|e| SessionStoreError::Other(Box::new(e)))?; 64 + self.store.select_session(&SessionHint::Did(did)).await 65 + } 66 + } 67 + 68 + /// Resolve a [`SessionHint`] against an OAuth [`ClientAuthStore`]. 69 + /// 70 + /// Exact key lookup avoids enumeration. `Any`, `Did`, and `Handle` use 71 + /// [`ClientAuthStore::list_session_keys`] as the generic fallback; stores that need more efficient 72 + /// indexed lookup can add specialized APIs later without changing the common key type. 73 + pub async fn resolve_oauth_session_hint<S, R>( 74 + store: &S, 75 + resolver: &R, 76 + hint: &SessionHint, 77 + ) -> Result<Option<OAuthSessionMatch>, SessionStoreError> 78 + where 79 + S: ClientAuthStore + SessionSelector<OAuthSessionMatch, Error = SessionStoreError> + Sync, 80 + R: IdentityResolver + Sync, 81 + { 82 + OAuthSessionSelector::new(store, resolver) 83 + .select_session(hint) 84 + .await 85 + } 86 + 87 + async fn oauth_match_for_did<S, D>( 88 + store: &S, 89 + did: &Did<D>, 90 + ) -> Result<Option<OAuthSessionMatch>, SessionStoreError> 91 + where 92 + S: ClientAuthStore, 93 + D: BosStr + Send + Sync, 94 + { 95 + for key in store.list_session_keys().await? { 96 + if key.did.as_str() == did.as_ref() { 97 + if let Some(matched) = oauth_match_for_key(store, key).await? { 98 + return Ok(Some(matched)); 99 + } 100 + } 101 + } 102 + Ok(None) 103 + } 104 + 105 + async fn oauth_match_for_key<S>( 106 + store: &S, 107 + key: SessionKey, 108 + ) -> Result<Option<OAuthSessionMatch>, SessionStoreError> 109 + where 110 + S: ClientAuthStore, 111 + { 112 + Ok(store 113 + .get_session(&key.did, key.session_id.as_str()) 114 + .await? 115 + .map(|session| OAuthSessionMatch { key, session })) 116 + } 117 + 14 118 /// Persistent storage backend for OAuth client sessions and in-flight authorization requests. 15 119 /// 16 120 /// Implementors are responsible for durably storing two categories of data: ··· 56 160 &self, 57 161 state: &str, 58 162 ) -> impl Future<Output = Result<(), SessionStoreError>>; 163 + 164 + /// List active OAuth session keys when the backend supports enumeration. 165 + fn list_session_keys( 166 + &self, 167 + ) -> impl Future<Output = Result<Vec<SessionKey>, SessionStoreError>> { 168 + async { Ok(Vec::new()) } 169 + } 59 170 } 60 171 61 172 /// An in-memory implementation of [`ClientAuthStore`], suitable for testing and single-process ··· 81 192 did: &Did<D>, 82 193 session_id: &str, 83 194 ) -> Result<Option<ClientSessionData>, SessionStoreError> { 84 - let key = format_smolstr!("{}_{}", did, session_id); 195 + let key = format_smolstr!("{}/{}", did, session_id); 85 196 Ok(self.sessions.get(&key).map(|v| v.clone())) 86 197 } 87 198 88 199 async fn upsert_session(&self, session: ClientSessionData) -> Result<(), SessionStoreError> { 89 - let key = format_smolstr!("{}_{}", session.account_did, session.session_id); 200 + let key = format_smolstr!("{}/{}", session.account_did, session.session_id); 90 201 self.sessions.insert(key, session); 91 202 Ok(()) 92 203 } ··· 96 207 did: &Did<D>, 97 208 session_id: &str, 98 209 ) -> Result<(), SessionStoreError> { 99 - let key = format_smolstr!("{}_{}", did, session_id); 210 + let key = format_smolstr!("{}/{}", did, session_id); 100 211 self.sessions.remove(&key); 101 212 Ok(()) 102 213 } ··· 120 231 async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 121 232 self.auth_reqs.remove(state); 122 233 Ok(()) 234 + } 235 + 236 + async fn list_session_keys(&self) -> Result<Vec<SessionKey>, SessionStoreError> { 237 + let mut sessions = self 238 + .sessions 239 + .iter() 240 + .map(|entry| { 241 + let session = entry.value(); 242 + SessionKey::new(session.account_did.clone(), session.session_id.clone()) 243 + }) 244 + .collect::<Vec<_>>(); 245 + sessions.sort(); 246 + Ok(sessions) 123 247 } 124 248 } 125 249 126 - impl<T: ClientAuthStore + Send + Sync> SessionStore<(Did, SmolStr), ClientSessionData> for Arc<T> { 250 + impl SessionSelector<OAuthSessionMatch> for MemoryAuthStore { 251 + type Error = SessionStoreError; 252 + 253 + async fn select_session( 254 + &self, 255 + hint: &SessionHint, 256 + ) -> Result<Option<OAuthSessionMatch>, Self::Error> { 257 + match hint { 258 + SessionHint::Any => { 259 + let Some(key) = self.list_session_keys().await?.into_iter().next() else { 260 + return Ok(None); 261 + }; 262 + oauth_match_for_key(self, key).await 263 + } 264 + SessionHint::Did(did) => oauth_match_for_did(self, did).await, 265 + SessionHint::Handle(_) | SessionHint::Identifier(_) => Ok(None), 266 + SessionHint::Key(key) => oauth_match_for_key(self, key.clone()).await, 267 + } 268 + } 269 + } 270 + 271 + impl<T: ClientAuthStore + Send + Sync> SessionStore<SessionKey, ClientSessionData> for Arc<T> { 127 272 /// Get the current session if present. 128 - async fn get(&self, key: &(Did, SmolStr)) -> Option<ClientSessionData> { 129 - let (did, session_id) = key; 273 + async fn get(&self, key: &SessionKey) -> Option<ClientSessionData> { 130 274 self.as_ref() 131 - .get_session(did, session_id) 275 + .get_session(&key.did, key.session_id.as_str()) 132 276 .await 133 277 .ok() 134 278 .flatten() ··· 136 280 /// Persist the given session. 137 281 async fn set( 138 282 &self, 139 - _key: (Did, SmolStr), 283 + _key: SessionKey, 140 284 session: ClientSessionData, 141 285 ) -> Result<(), SessionStoreError> { 142 286 self.as_ref().upsert_session(session).await 143 287 } 144 288 /// Delete the given session. 145 - async fn del(&self, key: &(Did, SmolStr)) -> Result<(), SessionStoreError> { 146 - let (did, session_id) = key; 147 - self.as_ref().delete_session(did, session_id).await 289 + async fn del(&self, key: &SessionKey) -> Result<(), SessionStoreError> { 290 + self.as_ref() 291 + .delete_session(&key.did, key.session_id.as_str()) 292 + .await 293 + } 294 + 295 + async fn list_keys(&self) -> Result<Vec<SessionKey>, SessionStoreError> { 296 + self.as_ref().list_session_keys().await 297 + } 298 + } 299 + 300 + #[cfg(test)] 301 + mod tests { 302 + use super::*; 303 + use jacquard_common::deps::fluent_uri::Uri; 304 + 305 + use crate::scopes::Scopes; 306 + use crate::session::DpopClientData; 307 + use crate::types::{OAuthTokenType, TokenSet}; 308 + 309 + fn client_session(did: &'static str, session_id: &'static str) -> ClientSessionData { 310 + let account_did = Did::new_static(did).unwrap(); 311 + ClientSessionData { 312 + account_did: account_did.clone(), 313 + session_id: SmolStr::new_static(session_id), 314 + host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(), 315 + authserver_url: SmolStr::new_static("https://issuer.example.com"), 316 + authserver_token_endpoint: SmolStr::new_static("https://issuer.example.com/token"), 317 + authserver_revocation_endpoint: None, 318 + scopes: Scopes::empty(), 319 + dpop_data: DpopClientData { 320 + dpop_key: crate::utils::generate_key(&[SmolStr::new_static("ES256")]).unwrap(), 321 + dpop_authserver_nonce: SmolStr::default(), 322 + dpop_host_nonce: SmolStr::default(), 323 + }, 324 + token_set: TokenSet { 325 + iss: SmolStr::new_static("https://issuer.example.com"), 326 + sub: account_did, 327 + aud: SmolStr::new_static("https://pds.example.com"), 328 + scope: None, 329 + refresh_token: None, 330 + access_token: SmolStr::new_static("access"), 331 + token_type: OAuthTokenType::DPoP, 332 + expires_at: None, 333 + }, 334 + #[cfg(feature = "scope-check")] 335 + resolved_scopes: None, 336 + } 337 + } 338 + 339 + #[tokio::test] 340 + async fn memory_auth_store_lists_session_keys() { 341 + let store = MemoryAuthStore::new(); 342 + let session = client_session("did:plc:alice", "state"); 343 + store.upsert_session(session).await.unwrap(); 344 + 345 + assert_eq!( 346 + store.list_session_keys().await.unwrap(), 347 + vec![SessionKey::new( 348 + Did::new_static("did:plc:alice").unwrap(), 349 + "state" 350 + )] 351 + ); 352 + } 353 + 354 + #[tokio::test] 355 + async fn memory_auth_store_selects_sessions_without_identifier_fallback() { 356 + let store = MemoryAuthStore::new(); 357 + let alice = client_session("did:plc:alice", "state-a"); 358 + let alice_key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "state-a"); 359 + store.upsert_session(alice.clone()).await.unwrap(); 360 + store 361 + .upsert_session(client_session("did:plc:bob", "state-b")) 362 + .await 363 + .unwrap(); 364 + 365 + let matched = store 366 + .select_session(&SessionHint::Any) 367 + .await 368 + .unwrap() 369 + .expect("any match"); 370 + assert_eq!(matched.key, alice_key); 371 + assert_eq!(matched.session, alice); 372 + 373 + let matched = store 374 + .select_session(&SessionHint::Did(Did::new_static("did:plc:alice").unwrap())) 375 + .await 376 + .unwrap() 377 + .expect("did match"); 378 + assert_eq!(matched.key, alice_key); 379 + 380 + let matched = store 381 + .select_session(&SessionHint::Key(alice_key.clone())) 382 + .await 383 + .unwrap() 384 + .expect("key match"); 385 + assert_eq!(matched.key, alice_key); 386 + 387 + assert!( 388 + store 389 + .select_session(&SessionHint::Identifier("alice@example.com".into())) 390 + .await 391 + .unwrap() 392 + .is_none(), 393 + "identifier hints must not fall back to Any" 394 + ); 395 + } 396 + 397 + #[derive(Clone, Default)] 398 + struct CountingResolver { 399 + handle_calls: Arc<tokio::sync::RwLock<usize>>, 400 + } 401 + 402 + impl IdentityResolver for CountingResolver { 403 + fn options(&self) -> &jacquard_identity::resolver::ResolverOptions { 404 + use std::sync::LazyLock; 405 + static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> = 406 + LazyLock::new(jacquard_identity::resolver::ResolverOptions::default); 407 + &OPTS 408 + } 409 + 410 + async fn resolve_handle<S: BosStr + Sync>( 411 + &self, 412 + _handle: &jacquard_common::types::string::Handle<S>, 413 + ) -> Result<Did, jacquard_identity::resolver::IdentityError> { 414 + *self.handle_calls.write().await += 1; 415 + Ok(Did::new_static("did:plc:alice").unwrap()) 416 + } 417 + 418 + async fn resolve_did_doc<S: BosStr + Sync>( 419 + &self, 420 + _did: &Did<S>, 421 + ) -> Result< 422 + jacquard_identity::resolver::DidDocResponse, 423 + jacquard_identity::resolver::IdentityError, 424 + > { 425 + unreachable!("OAuth selector tests do not resolve DID documents") 426 + } 427 + } 428 + 429 + #[tokio::test] 430 + async fn oauth_session_selector_uses_store_before_handle_resolution() { 431 + let store = MemoryAuthStore::new(); 432 + let resolver = CountingResolver::default(); 433 + let alice = client_session("did:plc:alice", "state"); 434 + store.upsert_session(alice.clone()).await.unwrap(); 435 + 436 + assert!( 437 + OAuthSessionSelector::new(&store, &resolver) 438 + .select_session(&SessionHint::Identifier("alice@example.com".into())) 439 + .await 440 + .unwrap() 441 + .is_none(), 442 + "identifier hints should not trigger resolver fallback" 443 + ); 444 + assert_eq!(*resolver.handle_calls.read().await, 0); 445 + 446 + let matched = OAuthSessionSelector::new(&store, &resolver) 447 + .select_session(&SessionHint::Handle( 448 + jacquard_common::types::string::Handle::new_static("alice.bsky.social").unwrap(), 449 + )) 450 + .await 451 + .unwrap() 452 + .expect("resolver fallback DID match"); 453 + assert_eq!(matched.session, alice); 454 + assert_eq!(*resolver.handle_calls.read().await, 1); 455 + } 456 + 457 + #[tokio::test] 458 + async fn arc_memory_auth_store_is_session_store() { 459 + let store = Arc::new(MemoryAuthStore::new()); 460 + let session = client_session("did:plc:alice", "state"); 461 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "state"); 462 + 463 + SessionStore::set(&store, key.clone(), session.clone()) 464 + .await 465 + .unwrap(); 466 + assert_eq!(SessionStore::get(&store, &key).await, Some(session)); 467 + assert_eq!( 468 + SessionStore::list_keys(&store).await.unwrap(), 469 + vec![key.clone()] 470 + ); 471 + SessionStore::del(&store, &key).await.unwrap(); 472 + assert_eq!(SessionStore::get(&store, &key).await, None); 148 473 } 149 474 }
+87 -3
crates/jacquard-oauth/src/client.rs
··· 1 1 use crate::{ 2 2 atproto::atproto_client_metadata, 3 - authstore::ClientAuthStore, 3 + authstore::{ClientAuthStore, OAuthSessionMatch}, 4 4 dpop::DpopExt, 5 - error::{CallbackError, Result}, 5 + error::{CallbackError, OAuthError, Result}, 6 6 request::{OAuthMetadata, exchange_code, par}, 7 7 resolver::OAuthResolver, 8 8 scopes::Scopes, ··· 25 25 deps::fluent_uri::Uri, 26 26 error::{AuthError, ClientError, XrpcResult}, 27 27 http_client::HttpClient, 28 + session::{SessionHint, SessionSelector, SessionStoreError}, 28 29 types::{did::Did, string::Handle}, 29 30 xrpc::{ 30 31 CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest, XrpcResp, XrpcResponse, ··· 49 50 use smol_str::{SmolStr, ToSmolStr}; 50 51 use std::{str::FromStr, sync::Arc}; 51 52 use tokio::sync::RwLock; 53 + 54 + /// Result of resuming an OAuth session or starting a new authorization flow. 55 + pub enum OAuthResumeOrLogin<T, S> 56 + where 57 + T: OAuthResolver, 58 + S: ClientAuthStore, 59 + { 60 + /// A stored session was found and restored/refreshed. 61 + Resumed(OAuthSession<T, S>), 62 + /// No stored session matched; redirect the user to this login URL. 63 + LoginUrl(String), 64 + } 52 65 53 66 /// The top-level OAuth client responsible for driving the authorization flow. 54 67 pub struct OAuthClient<T, S> ··· 331 344 { 332 345 Ok(token_set) => { 333 346 let scopes = if let Some(scope) = &token_set.scope { 334 - Scopes::new(SmolStr::from(scope.as_str())) 347 + Scopes::new(scope.as_str().to_smolstr()) 335 348 .expect("Failed to parse scopes from token response") 336 349 } else { 337 350 Scopes::empty() ··· 380 393 .await 381 394 } 382 395 396 + /// Resume a stored session for `input`, or begin OAuth authorization and return a login URL. 397 + pub async fn resume_or_start_auth_for<Str: BosStr>( 398 + &self, 399 + input: impl AsRef<str>, 400 + options: AuthorizeOptions<Str>, 401 + ) -> Result<OAuthResumeOrLogin<T, S>> 402 + where 403 + S: SessionSelector<OAuthSessionMatch, Error = SessionStoreError>, 404 + Str: FromStr + Ord + Clone + core::fmt::Debug, 405 + <Str as FromStr>::Err: core::fmt::Debug, 406 + { 407 + let input = input.as_ref(); 408 + let hint = oauth_hint_from_input(input); 409 + match self.registry.store.select_session(&hint).await? { 410 + Some(matched) => Ok(OAuthResumeOrLogin::Resumed( 411 + self.restore(&matched.key.did, matched.key.session_id.as_str()) 412 + .await?, 413 + )), 414 + None => Ok(OAuthResumeOrLogin::LoginUrl( 415 + self.start_auth(input, options).await?, 416 + )), 417 + } 418 + } 419 + 420 + /// Resume a stored session for `hint`, or begin OAuth authorization from the hint identity. 421 + pub async fn resume_or_start_auth<Str: BosStr>( 422 + &self, 423 + hint: &SessionHint, 424 + options: AuthorizeOptions<Str>, 425 + ) -> Result<OAuthResumeOrLogin<T, S>> 426 + where 427 + S: SessionSelector<OAuthSessionMatch, Error = SessionStoreError>, 428 + Str: FromStr + Ord + Clone + core::fmt::Debug, 429 + <Str as FromStr>::Err: core::fmt::Debug, 430 + { 431 + match self.registry.store.select_session(hint).await? { 432 + Some(matched) => Ok(OAuthResumeOrLogin::Resumed( 433 + self.restore(&matched.key.did, matched.key.session_id.as_str()) 434 + .await?, 435 + )), 436 + None => { 437 + let input = oauth_start_auth_input_from_hint(hint)?; 438 + Ok(OAuthResumeOrLogin::LoginUrl( 439 + self.start_auth(input, options).await?, 440 + )) 441 + } 442 + } 443 + } 444 + 383 445 /// Revoke a session by deleting it from the backing store. 384 446 /// 385 447 /// Note: this removes the session from local storage but does **not** call the authorization ··· 391 453 session_id: &str, 392 454 ) -> Result<()> { 393 455 Ok(self.registry.del(did, session_id).await?) 456 + } 457 + } 458 + 459 + fn oauth_hint_from_input(input: &str) -> SessionHint<SmolStr> { 460 + if let Ok(did) = Did::new(input) { 461 + SessionHint::Did(did.convert()) 462 + } else if let Ok(handle) = Handle::new(input) { 463 + SessionHint::Handle(handle.convert()) 464 + } else { 465 + SessionHint::Identifier(SmolStr::from(input)) 466 + } 467 + } 468 + 469 + fn oauth_start_auth_input_from_hint(hint: &SessionHint) -> Result<SmolStr> { 470 + match hint { 471 + SessionHint::Did(did) => Ok(did.as_ref().to_smolstr()), 472 + SessionHint::Handle(handle) => Ok(handle.as_ref().to_smolstr()), 473 + SessionHint::Key(key) => Ok(key.did.as_str().to_smolstr()), 474 + SessionHint::Identifier(identifier) => Ok(identifier.clone()), 475 + SessionHint::Any => Err(OAuthError::InvalidRequest( 476 + "cannot start OAuth authorization from SessionHint::Any without an identity".into(), 477 + )), 394 478 } 395 479 } 396 480
+5
crates/jacquard-oauth/src/error.rs
··· 60 60 #[diagnostic(code(jacquard_oauth::form))] 61 61 Form(#[from] serde_html_form::ser::Error), 62 62 63 + /// Invalid OAuth helper input. 64 + #[error("invalid OAuth request: {0}")] 65 + #[diagnostic(code(jacquard_oauth::invalid_request))] 66 + InvalidRequest(String), 67 + 63 68 /// An error validating an authorization callback. 64 69 #[error(transparent)] 65 70 #[diagnostic(code(jacquard_oauth::callback))]
+53 -1
crates/jacquard-oauth/src/loopback.rs
··· 46 46 #![cfg(feature = "loopback")] 47 47 use crate::{ 48 48 atproto::AtprotoClientMetadata, 49 - authstore::ClientAuthStore, 49 + authstore::{ClientAuthStore, OAuthSessionMatch}, 50 50 client::OAuthClient, 51 51 dpop::DpopExt, 52 52 error::{CallbackError, OAuthError}, ··· 55 55 }; 56 56 use jacquard_common::IntoStatic; 57 57 use jacquard_common::deps::fluent_uri::Uri; 58 + use jacquard_common::session::{SessionHint, SessionSelector, SessionStoreError}; 59 + use jacquard_common::types::{did::Did, string::Handle}; 58 60 use rouille::Server; 59 61 use smol_str::{SmolStr, ToSmolStr}; 60 62 use std::net::SocketAddr; 61 63 use tokio::sync::mpsc; 64 + 65 + fn oauth_hint_from_input(input: &str) -> SessionHint<SmolStr> { 66 + if let Ok(did) = Did::new(input) { 67 + SessionHint::Did(did.convert()) 68 + } else if let Ok(handle) = Handle::new(input) { 69 + SessionHint::Handle(handle.convert()) 70 + } else { 71 + SessionHint::Identifier(SmolStr::from(input)) 72 + } 73 + } 62 74 63 75 /// Port selection strategy for the loopback OAuth callback server. 64 76 #[derive(Clone, Debug)] ··· 315 327 } 316 328 .into_static() 317 329 } 330 + 331 + /// Resume a stored session for the input identity, or drive the full OAuth flow using a local loopback server. 332 + pub async fn resume_or_login_with_local_server( 333 + &self, 334 + input: impl AsRef<str>, 335 + opts: AuthorizeOptions<SmolStr>, 336 + cfg: LoopbackConfig, 337 + ) -> crate::error::Result<super::client::OAuthSession<T, S>> 338 + where 339 + S: SessionSelector<OAuthSessionMatch, Error = SessionStoreError>, 340 + { 341 + let input_ref = input.as_ref(); 342 + let hint = oauth_hint_from_input(input_ref); 343 + if let Some(matched) = self.registry.store.select_session(&hint).await? { 344 + return self 345 + .restore(&matched.key.did, matched.key.session_id.as_str()) 346 + .await; 347 + } 348 + self.login_with_local_server(input_ref, opts, cfg).await 349 + } 318 350 } 319 351 320 352 #[cfg(feature = "scope-check")] ··· 382 414 } 383 415 384 416 handle_localhost_callback(handle, &flow_client, &cfg).await 417 + } 418 + 419 + /// Resume a stored session for the input identity, or drive the full OAuth flow using a local loopback server. 420 + pub async fn resume_or_login_with_local_server( 421 + &self, 422 + input: impl AsRef<str>, 423 + opts: AuthorizeOptions<SmolStr>, 424 + cfg: LoopbackConfig, 425 + ) -> crate::error::Result<super::client::OAuthSession<T, S>> 426 + where 427 + S: SessionSelector<OAuthSessionMatch, Error = SessionStoreError>, 428 + { 429 + let input_ref = input.as_ref(); 430 + let hint = oauth_hint_from_input(input_ref); 431 + if let Some(matched) = self.registry.store.select_session(&hint).await? { 432 + return self 433 + .restore(&matched.key.did, matched.key.session_id.as_str()) 434 + .await; 435 + } 436 + self.login_with_local_server(input_ref, opts, cfg).await 385 437 } 386 438 387 439 /// Builds a [`crate::session::ClientData`] for use with the local loopback server method of OAuth.
+3 -3
crates/jacquard-oauth/src/request.rs
··· 577 577 .await?; 578 578 579 579 let scopes = if let Some(scope) = &metadata.client_metadata.scope { 580 - Scopes::new(SmolStr::from(scope.as_ref())).expect("Failed to parse scopes") 580 + Scopes::new(scope.as_ref().to_smolstr()).expect("Failed to parse scopes") 581 581 } else { 582 582 Scopes::empty() 583 583 }; ··· 655 655 session_data.update_with_tokens(&TokenSet { 656 656 iss, 657 657 sub: session_data.token_set.sub.clone(), 658 - aud: SmolStr::from(aud.as_str()), 658 + aud: aud.as_str().to_smolstr(), 659 659 scope: response.scope, 660 660 access_token: response.access_token, 661 661 refresh_token: response.refresh_token, ··· 719 719 Ok(TokenSet { 720 720 iss, 721 721 sub, 722 - aud: SmolStr::from(aud.as_str()), 722 + aud: aud.as_str().to_smolstr(), 723 723 scope: token_response.scope, 724 724 access_token: token_response.access_token, 725 725 refresh_token: token_response.refresh_token,
+1 -1
crates/jacquard-oauth/src/scopes.rs
··· 3035 3035 fn test_scopes_buffer_size_limit() { 3036 3036 // Test buffer exceeding u16 limit is rejected. 3037 3037 let too_long = "a".repeat(u16::MAX as usize + 1); 3038 - let smol = SmolStr::from(too_long.as_str()); 3038 + let smol = too_long.as_str().to_smolstr(); 3039 3039 let result = Scopes::new(smol); 3040 3040 assert!(result.is_err()); 3041 3041 }
+2 -2
crates/jacquard-oauth/src/session.rs
··· 24 24 }; 25 25 use jose_jwk::Key; 26 26 use serde::{Deserialize, Serialize}; 27 - use smol_str::{SmolStr, format_smolstr}; 27 + use smol_str::{SmolStr, ToSmolStr, format_smolstr}; 28 28 use tokio::sync::Mutex; 29 29 30 30 /// Provides DPoP key material and per-server nonces to the DPoP proof-building machinery. ··· 139 139 { 140 140 if let Some(scope_str) = token_set.scope.as_ref() { 141 141 // Parse scopes from the returned scope string, converting to the appropriate backing type 142 - let scopes_smol = Scopes::new(SmolStr::from(scope_str.as_ref())) 142 + let scopes_smol = Scopes::new(scope_str.as_ref().to_smolstr()) 143 143 .expect("server returned invalid scopes in token refresh"); 144 144 self.scopes = scopes_smol.convert(); 145 145 }
+38 -2
crates/jacquard/src/client.rs
··· 54 54 use jacquard_common::types::blob::{Blob, MimeType}; 55 55 use jacquard_common::types::collection::Collection; 56 56 #[cfg(feature = "api")] 57 + use jacquard_common::types::did_doc::DidDocument; 58 + #[cfg(feature = "api")] 57 59 use jacquard_common::types::ident::AtIdentifier; 58 60 use jacquard_common::types::recordkey::{RecordKey, Rkey}; 59 61 use jacquard_common::types::string::AtUri; ··· 479 481 /// App password session information from `com.atproto.server.createSession` 480 482 /// 481 483 /// Contains the access and refresh tokens along with user identity information. 482 - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 484 + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 483 485 pub struct AtpSession { 484 486 /// Access token (JWT) used for authenticated requests 485 487 pub access_jwt: SmolStr, ··· 489 491 pub did: Did, 490 492 /// User's handle (e.g., "alice.bsky.social") 491 493 pub handle: Handle, 494 + /// Account PDS endpoint, when known. 495 + #[serde(skip_serializing_if = "Option::is_none")] 496 + pub pds: Option<Uri<String>>, 497 + } 498 + 499 + impl AtpSession { 500 + /// Return the known account PDS endpoint, if present. 501 + pub fn pds_endpoint(&self) -> Option<&Uri<String>> { 502 + self.pds.as_ref() 503 + } 504 + 505 + /// Merge a refresh response into this session, preserving the existing PDS unless 506 + /// the refresh response contains a parseable DID document PDS endpoint. 507 + #[cfg(feature = "api")] 508 + pub fn merge_refresh(&mut self, output: RefreshSessionOutput) { 509 + let pds = pds_from_data(output.did_doc.as_ref()).or_else(|| self.pds.clone()); 510 + self.access_jwt = output.access_jwt; 511 + self.refresh_jwt = output.refresh_jwt; 512 + self.did = output.did; 513 + self.handle = output.handle; 514 + self.pds = pds; 515 + } 492 516 } 493 517 494 518 impl IntoStatic for AtpSession { ··· 500 524 } 501 525 502 526 #[cfg(feature = "api")] 527 + pub(crate) fn pds_from_data<S: BosStr>( 528 + data: Option<&jacquard_common::types::value::Data<S>>, 529 + ) -> Option<Uri<String>> { 530 + let doc: DidDocument = serde::Deserialize::deserialize(data?).ok()?; 531 + doc.pds_endpoint().map(|uri| uri.to_owned()) 532 + } 533 + 534 + #[cfg(feature = "api")] 503 535 impl From<CreateSessionOutput> for AtpSession { 504 536 fn from(output: CreateSessionOutput) -> Self { 537 + let pds = pds_from_data(output.did_doc.as_ref()); 505 538 Self { 506 539 access_jwt: output.access_jwt, 507 540 refresh_jwt: output.refresh_jwt, 508 541 did: output.did, 509 542 handle: output.handle, 543 + pds, 510 544 } 511 545 } 512 546 } ··· 514 548 #[cfg(feature = "api")] 515 549 impl From<RefreshSessionOutput> for AtpSession { 516 550 fn from(output: RefreshSessionOutput) -> Self { 551 + let pds = pds_from_data(output.did_doc.as_ref()); 517 552 Self { 518 553 access_jwt: output.access_jwt, 519 554 refresh_jwt: output.refresh_jwt, 520 555 did: output.did, 521 556 handle: output.handle, 557 + pds, 522 558 } 523 559 } 524 560 } ··· 1239 1275 CredentialSession::<S, T, W>::session_info(self) 1240 1276 .await 1241 1277 // Convert the SmolStr session id to CowStr<'static>. 1242 - .map(|key| (key.0, Some(key.1))) 1278 + .map(|key| (key.did, Some(key.session_id))) 1243 1279 } 1244 1280 } 1245 1281 fn endpoint(&self) -> impl Future<Output = Uri<String>> {
+6 -6
crates/jacquard/src/client/bff_session.rs
··· 151 151 #[cfg(target_arch = "wasm32")] 152 152 impl SessionStore<SessionKey, AtpSession> for BrowserAuthStore { 153 153 fn get(&self, key: &SessionKey) -> impl Future<Output = Option<AtpSession>> + Send { 154 - let key = Self::session_key(&key.0, &key.1); 154 + let key = Self::session_key(&key.did, key.session_id.as_str()); 155 155 async move { 156 156 match LocalStorage::get::<serde_json::Value>(&key) { 157 157 Ok(value) => { ··· 170 170 session: AtpSession, 171 171 ) -> impl Future<Output = Result<(), SessionStoreError>> + Send { 172 172 async move { 173 - let key = Self::session_key(&key.0, &key.1); 173 + let key = Self::session_key(&key.did, key.session_id.as_str()); 174 174 175 175 let value = serde_json::to_value(&session) 176 176 .map_err(|e| SessionStoreError::Other(format!("Serialize error: {}", e).into()))?; ··· 184 184 } 185 185 186 186 fn del(&self, key: &SessionKey) -> impl Future<Output = Result<(), SessionStoreError>> + Send { 187 - let key = Self::session_key(&key.0, &key.1); 187 + let key = Self::session_key(&key.did, key.session_id.as_str()); 188 188 async move { 189 189 LocalStorage::delete(&key); 190 190 Ok(()) ··· 196 196 #[cfg(target_arch = "wasm32")] 197 197 impl SessionStore<SessionKey, SessionKey> for BrowserAuthStore { 198 198 fn get(&self, key: &SessionKey) -> impl Future<Output = Option<SessionKey>> + Send { 199 - let key = Self::session_key(&key.0, &key.1); 199 + let key = Self::session_key(&key.did, key.session_id.as_str()); 200 200 async move { 201 201 match LocalStorage::get::<serde_json::Value>(&key) { 202 202 Ok(value) => { ··· 215 215 session: SessionKey, 216 216 ) -> impl Future<Output = Result<(), SessionStoreError>> + Send { 217 217 async move { 218 - let key = Self::session_key(&key.0, &key.1); 218 + let key = Self::session_key(&key.did, key.session_id.as_str()); 219 219 220 220 let value = serde_json::to_value(&session) 221 221 .map_err(|e| SessionStoreError::Other(format!("Serialize error: {}", e).into()))?; ··· 229 229 } 230 230 231 231 fn del(&self, key: &SessionKey) -> impl Future<Output = Result<(), SessionStoreError>> + Send { 232 - let key = Self::session_key(&key.0, &key.1); 232 + let key = Self::session_key(&key.did, key.session_id.as_str()); 233 233 async move { 234 234 LocalStorage::delete(&key); 235 235 Ok(())
+309 -103
crates/jacquard/src/client/credential_session.rs
··· 9 9 deps::fluent_uri::Uri, 10 10 error::{AuthError, ClientError, XrpcResult}, 11 11 http_client::HttpClient, 12 - session::SessionStore, 12 + session::{MemorySessionStore, SessionHint, SessionSelector, SessionStore}, 13 13 types::{did::Did, string::Handle}, 14 14 xrpc::{CallOptions, Response, XrpcClient, XrpcExt, XrpcRequest, XrpcResp, XrpcResponse}, 15 15 }; 16 16 #[cfg(feature = "streaming")] 17 17 use serde::Serialize; 18 - use smol_str::SmolStr; 18 + use smol_str::{SmolStr, ToSmolStr}; 19 19 use tokio::sync::RwLock; 20 20 21 21 use crate::client::AtpSession; 22 + #[cfg(feature = "websocket")] 23 + use jacquard_common::websocket::{WebSocketClient, WebSocketConnection}; 24 + #[cfg(feature = "websocket")] 25 + use jacquard_common::xrpc::XrpcSubscription; 22 26 use jacquard_identity::resolver::{ 23 27 DidDocResponse, IdentityError, IdentityResolver, ResolverOptions, 24 28 }; 25 - use std::any::Any; 29 + 30 + pub use jacquard_common::session::SessionKey; 31 + 32 + /// App-password session lookup result. 33 + #[derive(Debug, Clone, PartialEq, Eq)] 34 + pub struct CredentialSessionMatch { 35 + /// Matched session key. 36 + pub key: SessionKey, 37 + /// Stored app-password session for the matched key. 38 + pub session: AtpSession, 39 + } 40 + 41 + /// Result of trying to resume an app-password session. 42 + #[derive(Debug, Clone, PartialEq, Eq)] 43 + pub enum CredentialResumeResult { 44 + /// A stored session was found and activated. 45 + Resumed(AtpSession), 46 + /// No stored session matched; login credentials are required. 47 + LoginRequired(CredentialLoginChallenge), 48 + } 49 + 50 + /// Login identity details derived from a failed resume hint. 51 + #[derive(Debug, Clone, PartialEq, Eq)] 52 + pub struct CredentialLoginChallenge { 53 + /// Login identifier known from the hint, if any. 54 + pub identifier: Option<SmolStr>, 55 + /// Session id known from the hint, if any. 56 + pub session_id: Option<SmolStr>, 57 + } 58 + 59 + /// Options for hint/challenge-based app-password login helpers. 60 + #[derive(Debug, Clone)] 61 + pub struct CredentialLoginOptions<'a> { 62 + /// App-password or account password. 63 + pub password: CowStr<'a>, 64 + /// Login identifier override, required when the challenge has no identifier. 65 + pub identifier: Option<CowStr<'a>>, 66 + /// Whether taken-down accounts are allowed. 67 + pub allow_takendown: Option<bool>, 68 + /// Optional auth factor token. 69 + pub auth_factor_token: Option<CowStr<'a>>, 70 + /// Explicit PDS/entryway endpoint to use for login. 71 + pub pds: Option<Uri<String>>, 72 + } 73 + 74 + /// Resolver-backed app-password session selector. 75 + /// 76 + /// This adapter returns richer credential-session match data while keeping selection pluggable: 77 + /// database-backed stores can provide their own [`SessionSelector`] implementation with more 78 + /// efficient indexed lookup. 79 + pub struct CredentialSessionSelector<'a, S, R> { 80 + store: &'a S, 81 + resolver: &'a R, 82 + } 83 + 84 + impl<'a, S, R> CredentialSessionSelector<'a, S, R> { 85 + /// Create a selector over an app-password session store and identity resolver. 86 + pub fn new(store: &'a S, resolver: &'a R) -> Self { 87 + Self { store, resolver } 88 + } 89 + } 90 + 91 + impl<S, R> SessionSelector<CredentialSessionMatch> for CredentialSessionSelector<'_, S, R> 92 + where 93 + S: SessionStore<SessionKey, AtpSession> 94 + + SessionSelector<CredentialSessionMatch, Error = ClientError> 95 + + Sync, 96 + R: IdentityResolver + Sync, 97 + { 98 + type Error = ClientError; 99 + 100 + async fn select_session( 101 + &self, 102 + hint: &SessionHint, 103 + ) -> Result<Option<CredentialSessionMatch>, Self::Error> { 104 + if let Some(matched) = self.store.select_session(hint).await? { 105 + return Ok(Some(matched)); 106 + } 26 107 27 - #[cfg(feature = "websocket")] 28 - use jacquard_common::websocket::{WebSocketClient, WebSocketConnection}; 29 - #[cfg(feature = "websocket")] 30 - use jacquard_common::xrpc::XrpcSubscription; 108 + let SessionHint::Handle(handle) = hint else { 109 + return Ok(None); 110 + }; 31 111 32 - /// Storage key for app‑password sessions: `(account DID, session id)`. 33 - #[derive(Debug, Clone, PartialEq, Eq, Hash)] 34 - pub struct SessionKey(pub Did, pub SmolStr); 112 + let did = self.resolver.resolve_handle(handle).await?; 113 + self.store.select_session(&SessionHint::Did(did)).await 114 + } 115 + } 116 + 117 + /// Resolve a session hint against an app-password [`SessionStore`]. 118 + /// 119 + /// This is a convenience wrapper around [`CredentialSessionSelector`]. 120 + pub async fn resolve_credential_session_hint<S, R>( 121 + store: &S, 122 + resolver: &R, 123 + hint: &SessionHint, 124 + ) -> Result<Option<CredentialSessionMatch>, ClientError> 125 + where 126 + S: SessionStore<SessionKey, AtpSession> 127 + + SessionSelector<CredentialSessionMatch, Error = ClientError> 128 + + Sync, 129 + R: IdentityResolver + Sync, 130 + { 131 + CredentialSessionSelector::new(store, resolver) 132 + .select_session(hint) 133 + .await 134 + } 135 + 136 + async fn match_credential_session_key<S>( 137 + store: &S, 138 + key: SessionKey, 139 + ) -> Result<Option<CredentialSessionMatch>, ClientError> 140 + where 141 + S: SessionStore<SessionKey, AtpSession>, 142 + { 143 + Ok(store 144 + .get(&key) 145 + .await 146 + .map(|session| CredentialSessionMatch { key, session })) 147 + } 148 + 149 + impl SessionSelector<CredentialSessionMatch> for MemorySessionStore<SessionKey, AtpSession> { 150 + type Error = ClientError; 151 + 152 + async fn select_session( 153 + &self, 154 + hint: &SessionHint, 155 + ) -> Result<Option<CredentialSessionMatch>, Self::Error> { 156 + match hint { 157 + SessionHint::Any => { 158 + let Some(key) = self.list_keys().await?.into_iter().next() else { 159 + return Ok(None); 160 + }; 161 + match_credential_session_key(self, key).await 162 + } 163 + SessionHint::Did(did) => { 164 + for key in self.list_keys().await? { 165 + if key.did.as_str() == did.as_ref() { 166 + if let Some(matched) = match_credential_session_key(self, key).await? { 167 + return Ok(Some(matched)); 168 + } 169 + } 170 + } 171 + Ok(None) 172 + } 173 + SessionHint::Handle(handle) => { 174 + for key in self.list_keys().await? { 175 + if let Some(session) = self.get(&key).await { 176 + if session.handle.as_str() == handle.as_ref() { 177 + return Ok(Some(CredentialSessionMatch { key, session })); 178 + } 179 + } 180 + } 181 + Ok(None) 182 + } 183 + SessionHint::Key(key) => match_credential_session_key(self, key.clone()).await, 184 + SessionHint::Identifier(_) => Ok(None), 185 + } 186 + } 187 + } 188 + 189 + fn credential_challenge_from_hint(hint: &SessionHint) -> CredentialLoginChallenge { 190 + match hint { 191 + SessionHint::Any => CredentialLoginChallenge { 192 + identifier: None, 193 + session_id: None, 194 + }, 195 + SessionHint::Did(did) => CredentialLoginChallenge { 196 + identifier: Some(did.as_str().to_smolstr()), 197 + session_id: None, 198 + }, 199 + SessionHint::Handle(handle) => CredentialLoginChallenge { 200 + identifier: Some(handle.as_str().to_smolstr()), 201 + session_id: None, 202 + }, 203 + SessionHint::Key(key) => CredentialLoginChallenge { 204 + identifier: Some(key.did.as_str().to_smolstr()), 205 + session_id: Some(key.session_id.clone()), 206 + }, 207 + SessionHint::Identifier(identifier) => CredentialLoginChallenge { 208 + identifier: Some(identifier.clone()), 209 + session_id: None, 210 + }, 211 + } 212 + } 35 213 36 214 /// Stateful client for app‑password based sessions. 37 215 /// ··· 160 338 let session = self.store.get(&key).await; 161 339 let endpoint = self.endpoint().await; 162 340 let mut opts = self.options.read().await.clone(); 163 - opts.auth = session.map(|s| AuthorizationToken::Bearer(s.refresh_jwt)); 341 + opts.auth = session 342 + .as_ref() 343 + .map(|s| AuthorizationToken::Bearer(s.refresh_jwt.clone())); 164 344 let response = self 165 345 .client 166 346 .xrpc(endpoint.borrow()) ··· 173 353 .with_url("com.atproto.server.refreshSession") 174 354 })?; 175 355 176 - let new_session: AtpSession = refresh.into(); 356 + let mut new_session = session.unwrap_or_else(|| AtpSession::from(refresh.clone())); 357 + new_session.merge_refresh(refresh); 177 358 let token = AuthorizationToken::Bearer(new_session.access_jwt.clone()); 178 359 self.store.set(key, new_session).await.map_err(|e| { 179 360 ClientError::from(e).with_context("failed to persist refreshed session to store") ··· 201 382 allow_takendown: Option<bool>, 202 383 auth_factor_token: Option<CowStr<'_>>, 203 384 pds: Option<Uri<String>>, 204 - ) -> std::result::Result<AtpSession, ClientError> 205 - where 206 - S: Any + 'static, 207 - { 385 + ) -> std::result::Result<AtpSession, ClientError> { 208 386 #[cfg(feature = "tracing")] 209 387 let _span = 210 388 tracing::info_span!("credential_session_login", identifier = %identifier).entered(); ··· 284 462 .with_help("check identifier and password are correct") 285 463 .with_url("com.atproto.server.createSession") 286 464 })?; 287 - let session = AtpSession::from(out); 465 + let mut session = AtpSession::from(out); 466 + if session.pds.is_none() { 467 + session.pds = Some(jacquard_common::xrpc::normalize_base_uri(pds.clone())); 468 + } 288 469 289 470 let sid = session_id.unwrap_or_else(|| CowStr::new_static("session")); 290 - let key = SessionKey(session.did.clone().convert::<SmolStr>(), SmolStr::from(sid)); 471 + let key = SessionKey::new(session.did.clone().convert::<SmolStr>(), SmolStr::from(sid)); 291 472 self.store 292 473 .set(key.clone(), session.clone()) 293 474 .await 294 475 .map_err(|e| ClientError::from(e).with_context("failed to persist session to store"))?; 295 - // If using FileAuthStore, persist PDS for faster resume 296 - if let Some(file_store) = 297 - (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 298 - { 299 - let _ = file_store.set_atp_pds(&key, &pds); 300 - } 301 476 // Activate 302 477 *self.key.write().await = Some(key); 303 - let pds_uri = jacquard_common::xrpc::normalize_base_uri(pds); 478 + let pds_uri = jacquard_common::xrpc::normalize_base_uri(session.pds.clone().unwrap_or(pds)); 304 479 *self.endpoint.write().await = Some(pds_uri); 305 480 306 481 Ok(session) 307 482 } 308 483 484 + async fn activate_session( 485 + &self, 486 + key: SessionKey, 487 + mut session: AtpSession, 488 + ) -> std::result::Result<AtpSession, ClientError> { 489 + let pds = if let Some(pds) = session.pds.clone() { 490 + pds 491 + } else { 492 + let resp = self.client.resolve_did_doc(&session.did).await?; 493 + let pds = resp 494 + .into_owned()? 495 + .pds_endpoint() 496 + .map(|u| u.to_owned()) 497 + .ok_or_else(|| { 498 + ClientError::invalid_request("missing PDS endpoint") 499 + .with_help("DID document must include a PDS service endpoint") 500 + })?; 501 + session.pds = Some(jacquard_common::xrpc::normalize_base_uri(pds)); 502 + self.store 503 + .set(key.clone(), session.clone()) 504 + .await 505 + .map_err(|e| { 506 + ClientError::from(e).with_context("failed to persist session PDS to store") 507 + })?; 508 + session.pds.clone().expect("pds just set") 509 + }; 510 + 511 + *self.key.write().await = Some(key); 512 + *self.endpoint.write().await = Some(jacquard_common::xrpc::normalize_base_uri(pds)); 513 + Ok(session) 514 + } 515 + 516 + /// Try to resume a stored app-password session for the given hint. 517 + pub async fn resume(&self, hint: &SessionHint) -> Result<CredentialResumeResult, ClientError> 518 + where 519 + S: SessionSelector<CredentialSessionMatch, Error = ClientError>, 520 + { 521 + match self.store.select_session(hint).await? { 522 + Some(matched) => { 523 + let session = self.activate_session(matched.key, matched.session).await?; 524 + Ok(CredentialResumeResult::Resumed(session)) 525 + } 526 + None => Ok(CredentialResumeResult::LoginRequired( 527 + credential_challenge_from_hint(hint), 528 + )), 529 + } 530 + } 531 + 532 + /// Login using identity details from a resume challenge. 533 + pub async fn login_from_challenge( 534 + &self, 535 + challenge: CredentialLoginChallenge, 536 + options: CredentialLoginOptions<'_>, 537 + ) -> Result<AtpSession, ClientError> { 538 + let identifier = challenge 539 + .identifier 540 + .map(CowStr::from) 541 + .or(options.identifier) 542 + .ok_or_else(|| { 543 + ClientError::invalid_request("missing login identifier").with_help( 544 + "provide CredentialLoginOptions::identifier for an Any resume challenge", 545 + ) 546 + })?; 547 + self.login( 548 + identifier, 549 + options.password, 550 + challenge.session_id.map(CowStr::from), 551 + options.allow_takendown, 552 + options.auth_factor_token, 553 + options.pds, 554 + ) 555 + .await 556 + } 557 + 558 + /// Login using identity details derived from a session hint. 559 + pub async fn login_with_hint( 560 + &self, 561 + hint: &SessionHint, 562 + options: CredentialLoginOptions<'_>, 563 + ) -> Result<AtpSession, ClientError> { 564 + self.login_from_challenge(credential_challenge_from_hint(hint), options) 565 + .await 566 + } 567 + 568 + /// Resume a stored session if available, otherwise login using the provided options. 569 + pub async fn resume_or_login( 570 + &self, 571 + hint: &SessionHint, 572 + options: CredentialLoginOptions<'_>, 573 + ) -> Result<AtpSession, ClientError> 574 + where 575 + S: SessionSelector<CredentialSessionMatch, Error = ClientError>, 576 + { 577 + match self.resume(hint).await? { 578 + CredentialResumeResult::Resumed(session) => Ok(session), 579 + CredentialResumeResult::LoginRequired(challenge) => { 580 + self.login_from_challenge(challenge, options).await 581 + } 582 + } 583 + } 584 + 309 585 /// Restore a previously persisted app-password session and set base endpoint. 310 586 pub async fn restore( 311 587 &self, 312 588 did: Did, 313 589 session_id: CowStr<'_>, 314 - ) -> std::result::Result<(), ClientError> 315 - where 316 - S: Any + 'static, 317 - { 590 + ) -> std::result::Result<(), ClientError> { 318 591 #[cfg(feature = "tracing")] 319 592 let _span = 320 593 tracing::info_span!("credential_session_restore", did = %did, session_id = %session_id) 321 594 .entered(); 322 595 323 - let key = SessionKey(did.clone(), SmolStr::from(session_id.clone())); 596 + let key = SessionKey::new(did, SmolStr::from(session_id)); 324 597 let Some(sess) = self.store.get(&key).await else { 325 598 return Err(ClientError::auth(AuthError::NotAuthenticated)); 326 599 }; 327 - // Try to read cached PDS; otherwise resolve via DID 328 - let pds = if let Some(file_store) = 329 - (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 330 - { 331 - file_store.get_atp_pds(&key).ok().flatten().or_else(|| None) 332 - } else { 333 - None 334 - } 335 - .unwrap_or({ 336 - let resp = self.client.resolve_did_doc(&did).await?; 337 - resp.into_owned()? 338 - .pds_endpoint() 339 - .map(|u| u.to_owned()) 340 - .ok_or_else(|| { 341 - ClientError::invalid_request("missing PDS endpoint") 342 - .with_help("DID document must include a PDS service endpoint") 343 - })? 344 - }); 345 - 346 - // Activate 347 - *self.key.write().await = Some(key.clone()); 348 - let pds_uri = jacquard_common::xrpc::normalize_base_uri(pds); 349 - *self.endpoint.write().await = Some(pds_uri.clone()); 350 - // ensure store has the session (no-op if it existed) 351 - self.store 352 - .set( 353 - SessionKey( 354 - sess.did.clone().convert::<SmolStr>(), 355 - SmolStr::from(session_id), 356 - ), 357 - sess, 358 - ) 359 - .await?; 360 - if let Some(file_store) = 361 - (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 362 - { 363 - let _ = file_store.set_atp_pds(&key, &pds_uri); 364 - } 365 - Ok(()) 600 + self.activate_session(key, sess).await.map(|_| ()) 366 601 } 367 602 368 603 /// Switch to a different stored session (and refresh endpoint/PDS). ··· 370 605 &self, 371 606 did: Did, 372 607 session_id: CowStr<'_>, 373 - ) -> std::result::Result<(), ClientError> 374 - where 375 - S: Any + 'static, 376 - { 377 - let key = SessionKey(did.clone(), SmolStr::from(session_id)); 378 - if self.store.get(&key).await.is_none() { 608 + ) -> std::result::Result<(), ClientError> { 609 + let key = SessionKey::new(did, SmolStr::from(session_id)); 610 + let Some(sess) = self.store.get(&key).await else { 379 611 return Err(ClientError::auth(AuthError::NotAuthenticated)); 380 - } 381 - // Endpoint from store if cached, else resolve 382 - let pds = if let Some(file_store) = 383 - (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 384 - { 385 - file_store.get_atp_pds(&key).ok().flatten().or_else(|| None) 386 - } else { 387 - None 388 - } 389 - .unwrap_or({ 390 - let resp = self.client.resolve_did_doc(&did).await?; 391 - resp.into_owned()? 392 - .pds_endpoint() 393 - .map(|u| u.to_owned()) 394 - .ok_or_else(|| { 395 - ClientError::invalid_request("missing PDS endpoint") 396 - .with_help("DID document must include a PDS service endpoint") 397 - })? 398 - }); 399 - *self.key.write().await = Some(key.clone()); 400 - let pds_uri = jacquard_common::xrpc::normalize_base_uri(pds); 401 - *self.endpoint.write().await = Some(pds_uri.clone()); 402 - if let Some(file_store) = 403 - (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 404 - { 405 - let _ = file_store.set_atp_pds(&key, &pds_uri); 406 - } 407 - Ok(()) 612 + }; 613 + self.activate_session(key, sess).await.map(|_| ()) 408 614 } 409 615 410 616 /// Clear and delete the current session from the store.
+289 -146
crates/jacquard/src/client/token.rs
··· 1 + use jacquard_common::IntoStatic; 1 2 use jacquard_common::deps::fluent_uri::Uri; 2 - use jacquard_common::session::{FileTokenStore, SessionStore, SessionStoreError}; 3 + use jacquard_common::session::{ 4 + FileTokenStore, SessionHint, SessionKey, SessionSelector, SessionStore, SessionStoreError, 5 + }; 3 6 use jacquard_common::types::string::{Datetime, Did}; 4 7 use jacquard_oauth::scopes::Scopes; 5 8 use jacquard_oauth::session::{AuthRequestData, ClientSessionData, DpopClientData, DpopReqData}; 6 9 use jacquard_oauth::types::OAuthTokenType; 7 10 use jose_jwk::Key; 8 11 use serde::{Deserialize, Serialize}; 9 - use serde_json::Value; 10 12 use smol_str::SmolStr; 11 13 12 14 /// On-disk session records for app-password and OAuth flows, sharing a single JSON map. ··· 20 22 OAuthState(OAuthState), 21 23 } 22 24 23 - /// Minimal persisted representation of an app‑password session. 25 + /// Persisted representation of an app-password session plus its store-local session id. 24 26 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 25 27 pub struct StoredAtSession { 26 - /// Access token (JWT) 27 - access_jwt: String, 28 - /// Refresh token (JWT) 29 - refresh_jwt: String, 30 - /// Account DID 31 - did: String, 32 - /// Optional PDS endpoint for faster resume 33 - #[serde(skip_serializing_if = "std::option::Option::is_none")] 34 - pds: Option<String>, 35 28 /// Session id label (e.g., "session") 36 - session_id: String, 37 - /// Last known handle 38 - handle: String, 29 + pub session_id: String, 30 + /// Stored app-password session. 31 + pub session: crate::client::AtpSession, 39 32 } 40 33 41 34 /// Persisted OAuth client session (on-disk format). ··· 265 258 pub fn new(path: impl AsRef<std::path::Path>) -> Self { 266 259 Self(FileTokenStore::new(path)) 267 260 } 261 + 262 + fn atp_key(key: &SessionKey) -> String { 263 + format!("atp:{}", key) 264 + } 265 + 266 + fn oauth_key(key: &SessionKey) -> String { 267 + format!("oauth:{}", key) 268 + } 269 + 270 + fn oauth_state_key(state: &str) -> String { 271 + format!("oauth-state:{}", state) 272 + } 268 273 } 269 274 270 275 impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore { ··· 273 278 did: &Did<D>, 274 279 session_id: &str, 275 280 ) -> Result<Option<ClientSessionData>, SessionStoreError> { 276 - let key = format!("{}_{}", did, session_id); 277 - if let StoredSession::OAuth(session) = self 278 - .0 279 - .get(&key) 280 - .await 281 - .ok_or(SessionStoreError::Other("not found".into()))? 282 - { 281 + let key = SessionKey::new(did.borrow().into_static(), session_id); 282 + let Some(value) = self.0.get_value(&Self::oauth_key(&key))? else { 283 + return Ok(None); 284 + }; 285 + if let StoredSession::OAuth(session) = serde_json::from_value(value)? { 283 286 Ok(Some(session.into())) 284 287 } else { 285 288 Ok(None) ··· 287 290 } 288 291 289 292 async fn upsert_session(&self, session: ClientSessionData) -> Result<(), SessionStoreError> { 290 - let key = format!("{}_{}", session.account_did, session.session_id); 291 - self.0 292 - .set(key, StoredSession::OAuth(session.into())) 293 - .await?; 293 + let key = SessionKey::new(session.account_did.clone(), session.session_id.clone()); 294 + self.0.set_value( 295 + Self::oauth_key(&key), 296 + serde_json::to_value(StoredSession::OAuth(session.into()))?, 297 + )?; 294 298 Ok(()) 295 299 } 296 300 ··· 299 303 did: &Did<D>, 300 304 session_id: &str, 301 305 ) -> Result<(), SessionStoreError> { 302 - let key = format!("{}_{}", did, session_id); 303 - let file = std::fs::read_to_string(&self.0.path)?; 304 - let mut store: Value = serde_json::from_str(&file)?; 305 - let key_string = key.to_string(); 306 - if let Some(store) = store.as_object_mut() { 307 - store.remove(&key_string); 308 - 309 - std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 310 - Ok(()) 311 - } else { 312 - Err(SessionStoreError::Other("invalid store".into())) 313 - } 306 + let key = SessionKey::new(did.borrow().into_static(), session_id); 307 + self.0.remove_value(&Self::oauth_key(&key)) 314 308 } 315 309 316 310 async fn get_auth_req_info( 317 311 &self, 318 312 state: &str, 319 313 ) -> Result<Option<AuthRequestData>, SessionStoreError> { 320 - let key = format!("authreq_{}", state); 321 - if let StoredSession::OAuthState(auth_req) = self 322 - .0 323 - .get(&key) 324 - .await 325 - .ok_or(SessionStoreError::Other("not found".into()))? 326 - { 314 + let key = Self::oauth_state_key(state); 315 + let Some(value) = self.0.get_value(&key)? else { 316 + return Ok(None); 317 + }; 318 + if let StoredSession::OAuthState(auth_req) = serde_json::from_value(value)? { 327 319 Ok(Some(auth_req.into())) 328 320 } else { 329 321 Ok(None) ··· 334 326 &self, 335 327 auth_req_info: &AuthRequestData, 336 328 ) -> Result<(), SessionStoreError> { 337 - let key = format!("authreq_{}", auth_req_info.state); 329 + let key = Self::oauth_state_key(&auth_req_info.state); 338 330 let state = auth_req_info.clone().try_into().map_err( 339 331 |e: jacquard_common::deps::fluent_uri::ParseError| { 340 332 SessionStoreError::Other(Box::new(e)) 341 333 }, 342 334 )?; 343 - self.0.set(key, StoredSession::OAuthState(state)).await?; 335 + self.0 336 + .set_value(key, serde_json::to_value(StoredSession::OAuthState(state))?)?; 344 337 Ok(()) 345 338 } 346 339 347 340 async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 348 - let key = format!("authreq_{}", state); 349 - let file = std::fs::read_to_string(&self.0.path)?; 350 - let mut store: Value = serde_json::from_str(&file)?; 351 - let key_string = key.to_string(); 352 - if let Some(store) = store.as_object_mut() { 353 - store.remove(&key_string); 354 - 355 - std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 356 - Ok(()) 357 - } else { 358 - Err(SessionStoreError::Other("invalid store".into())) 359 - } 341 + let key = Self::oauth_state_key(state); 342 + self.0.remove_value(&key) 360 343 } 361 - } 362 344 363 - impl FileAuthStore { 364 - /// Update the persisted PDS endpoint for an app-password session (best-effort). 365 - pub fn set_atp_pds( 366 - &self, 367 - key: &crate::client::credential_session::SessionKey, 368 - pds: &Uri<String>, 369 - ) -> Result<(), SessionStoreError> { 370 - let key_str = format!("{}_{}", key.0, key.1); 371 - let file = std::fs::read_to_string(&self.0.path)?; 372 - let mut store: Value = serde_json::from_str(&file)?; 373 - if let Some(map) = store.as_object_mut() { 374 - if let Some(value) = map.get_mut(&key_str) { 375 - if let Some(outer) = value.as_object_mut() { 376 - if let Some(inner) = outer.get_mut("Atp").and_then(|v| v.as_object_mut()) { 377 - inner.insert( 378 - "pds".to_string(), 379 - serde_json::Value::String(pds.as_str().to_string()), 380 - ); 381 - std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 382 - return Ok(()); 383 - } 384 - } 345 + async fn list_session_keys(&self) -> Result<Vec<SessionKey>, SessionStoreError> { 346 + let mut keys = Vec::new(); 347 + for (_key, value) in self.0.entries()? { 348 + if let Ok(StoredSession::OAuth(session)) = 349 + serde_json::from_value::<StoredSession>(value) 350 + { 351 + keys.push(SessionKey::new( 352 + Did::new_owned(session.account_did).expect("stored DID should be valid"), 353 + session.session_id, 354 + )); 385 355 } 386 356 } 387 - Err(SessionStoreError::Other("invalid store".into())) 388 - } 389 - 390 - /// Read the persisted PDS endpoint for an app-password session, if present. 391 - pub fn get_atp_pds( 392 - &self, 393 - key: &crate::client::credential_session::SessionKey, 394 - ) -> Result<Option<Uri<String>>, SessionStoreError> { 395 - let key_str = format!("{}_{}", key.0, key.1); 396 - let file = std::fs::read_to_string(&self.0.path)?; 397 - let store: Value = serde_json::from_str(&file)?; 398 - if let Some(value) = store.get(&key_str) { 399 - if let Some(obj) = value.as_object() { 400 - if let Some(serde_json::Value::Object(inner)) = obj.get("Atp") { 401 - if let Some(serde_json::Value::String(pds)) = inner.get("pds") { 402 - return Ok(Uri::parse(pds.as_str()).ok().map(|u| u.to_owned())); 403 - } 404 - } 405 - } 406 - } 407 - Ok(None) 357 + Ok(keys) 408 358 } 409 359 } 410 360 411 - impl 412 - jacquard_common::session::SessionStore< 413 - crate::client::credential_session::SessionKey, 414 - crate::client::AtpSession, 415 - > for FileAuthStore 416 - { 417 - async fn get( 418 - &self, 419 - key: &crate::client::credential_session::SessionKey, 420 - ) -> Option<crate::client::AtpSession> { 421 - let key_str = format!("{}_{}", key.0, key.1); 422 - if let Some(StoredSession::Atp(stored)) = self.0.get(&key_str).await { 423 - Some(crate::client::AtpSession { 424 - access_jwt: stored.access_jwt.into(), 425 - refresh_jwt: stored.refresh_jwt.into(), 426 - did: stored.did.into(), 427 - handle: stored.handle.into(), 428 - }) 361 + impl SessionStore<SessionKey, crate::client::AtpSession> for FileAuthStore { 362 + async fn get(&self, key: &SessionKey) -> Option<crate::client::AtpSession> { 363 + let value = self.0.get_value(&Self::atp_key(key)).ok()??; 364 + if let Ok(StoredSession::Atp(stored)) = serde_json::from_value::<StoredSession>(value) { 365 + Some(stored.session) 429 366 } else { 430 367 None 431 368 } ··· 433 370 434 371 async fn set( 435 372 &self, 436 - key: crate::client::credential_session::SessionKey, 373 + key: SessionKey, 437 374 session: crate::client::AtpSession, 438 375 ) -> Result<(), jacquard_common::session::SessionStoreError> { 439 - let key_str = format!("{}_{}", key.0, key.1); 440 376 let stored = StoredAtSession { 441 - access_jwt: session.access_jwt.to_string(), 442 - refresh_jwt: session.refresh_jwt.to_string(), 443 - did: session.did.to_string(), 444 - // pds endpoint is resolved on restore; do not persist 445 - pds: None, 446 - session_id: key.1.to_string(), 447 - handle: session.handle.to_string(), 377 + session_id: key.session_id.to_string(), 378 + session, 448 379 }; 449 - self.0.set(key_str, StoredSession::Atp(stored)).await 380 + self.0.set_value( 381 + Self::atp_key(&key), 382 + serde_json::to_value(StoredSession::Atp(stored))?, 383 + ) 450 384 } 451 385 452 386 async fn del( 453 387 &self, 454 - key: &crate::client::credential_session::SessionKey, 388 + key: &SessionKey, 455 389 ) -> Result<(), jacquard_common::session::SessionStoreError> { 456 - let key_str = format!("{}_{}", key.0, key.1); 457 - // Manual removal to mirror existing pattern 458 - let file = std::fs::read_to_string(&self.0.path)?; 459 - let mut store: serde_json::Value = serde_json::from_str(&file)?; 460 - if let Some(map) = store.as_object_mut() { 461 - map.remove(&key_str); 462 - std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 463 - Ok(()) 464 - } else { 465 - Err(jacquard_common::session::SessionStoreError::Other( 466 - "invalid store".into(), 467 - )) 390 + self.0.remove_value(&Self::atp_key(key)) 391 + } 392 + 393 + async fn list_keys(&self) -> Result<Vec<SessionKey>, SessionStoreError> { 394 + let mut keys = Vec::new(); 395 + for (_key, value) in self.0.entries()? { 396 + if let Ok(StoredSession::Atp(session)) = serde_json::from_value::<StoredSession>(value) 397 + { 398 + keys.push(SessionKey::new( 399 + session.session.did.clone(), 400 + session.session_id, 401 + )); 402 + } 403 + } 404 + Ok(keys) 405 + } 406 + } 407 + 408 + impl SessionSelector<crate::client::credential_session::CredentialSessionMatch> for FileAuthStore { 409 + type Error = jacquard_common::error::ClientError; 410 + 411 + async fn select_session( 412 + &self, 413 + hint: &SessionHint, 414 + ) -> Result<Option<crate::client::credential_session::CredentialSessionMatch>, Self::Error> 415 + { 416 + match hint { 417 + SessionHint::Any => { 418 + let Some(key) = SessionStore::list_keys(self).await?.into_iter().next() else { 419 + return Ok(None); 420 + }; 421 + Ok(SessionStore::get(self, &key).await.map(|session| { 422 + crate::client::credential_session::CredentialSessionMatch { key, session } 423 + })) 424 + } 425 + SessionHint::Did(did) => { 426 + for key in SessionStore::list_keys(self).await? { 427 + if key.did.as_str() == did.as_ref() { 428 + if let Some(session) = SessionStore::get(self, &key).await { 429 + return Ok(Some( 430 + crate::client::credential_session::CredentialSessionMatch { 431 + key, 432 + session, 433 + }, 434 + )); 435 + } 436 + } 437 + } 438 + Ok(None) 439 + } 440 + SessionHint::Handle(handle) => { 441 + for key in SessionStore::list_keys(self).await? { 442 + if let Some(session) = SessionStore::get(self, &key).await { 443 + if session.handle.as_str() == handle.as_ref() { 444 + return Ok(Some( 445 + crate::client::credential_session::CredentialSessionMatch { 446 + key, 447 + session, 448 + }, 449 + )); 450 + } 451 + } 452 + } 453 + Ok(None) 454 + } 455 + SessionHint::Key(key) => Ok(SessionStore::get(self, key).await.map(|session| { 456 + crate::client::credential_session::CredentialSessionMatch { 457 + key: key.clone(), 458 + session, 459 + } 460 + })), 461 + SessionHint::Identifier(_) => Ok(None), 462 + } 463 + } 464 + } 465 + 466 + impl SessionSelector<jacquard_oauth::authstore::OAuthSessionMatch> for FileAuthStore { 467 + type Error = SessionStoreError; 468 + 469 + async fn select_session( 470 + &self, 471 + hint: &SessionHint, 472 + ) -> Result<Option<jacquard_oauth::authstore::OAuthSessionMatch>, Self::Error> { 473 + match hint { 474 + SessionHint::Any => { 475 + let Some(key) = jacquard_oauth::authstore::ClientAuthStore::list_session_keys(self) 476 + .await? 477 + .into_iter() 478 + .next() 479 + else { 480 + return Ok(None); 481 + }; 482 + oauth_match_for_key_file(self, key).await 483 + } 484 + SessionHint::Did(did) => { 485 + for key in 486 + jacquard_oauth::authstore::ClientAuthStore::list_session_keys(self).await? 487 + { 488 + if key.did.as_str() == did.as_ref() { 489 + if let Some(matched) = oauth_match_for_key_file(self, key).await? { 490 + return Ok(Some(matched)); 491 + } 492 + } 493 + } 494 + Ok(None) 495 + } 496 + SessionHint::Handle(_) | SessionHint::Identifier(_) => Ok(None), 497 + SessionHint::Key(key) => oauth_match_for_key_file(self, key.clone()).await, 468 498 } 469 499 } 470 500 } 471 501 502 + async fn oauth_match_for_key_file( 503 + store: &FileAuthStore, 504 + key: SessionKey, 505 + ) -> Result<Option<jacquard_oauth::authstore::OAuthSessionMatch>, SessionStoreError> { 506 + Ok(jacquard_oauth::authstore::ClientAuthStore::get_session( 507 + store, 508 + &key.did, 509 + key.session_id.as_str(), 510 + ) 511 + .await? 512 + .map(|session| jacquard_oauth::authstore::OAuthSessionMatch { key, session })) 513 + } 514 + 472 515 #[cfg(test)] 473 516 mod tests { 474 517 use super::*; ··· 480 523 481 524 fn temp_file() -> PathBuf { 482 525 let mut p = std::env::temp_dir(); 483 - p.push(format!("jacquard-test-{}.json", std::process::id())); 526 + let nanos = std::time::SystemTime::now() 527 + .duration_since(std::time::UNIX_EPOCH) 528 + .unwrap() 529 + .as_nanos(); 530 + p.push(format!("jacquard-test-{}-{nanos}.json", std::process::id())); 484 531 p 485 532 } 486 533 534 + fn oauth_session(did: &'static str, session_id: &'static str) -> ClientSessionData { 535 + let account_did = Did::new_static(did).unwrap(); 536 + ClientSessionData { 537 + account_did: account_did.clone(), 538 + session_id: SmolStr::new_static(session_id), 539 + host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(), 540 + authserver_url: SmolStr::new_static("https://issuer.example.com"), 541 + authserver_token_endpoint: SmolStr::new_static("https://issuer.example.com/token"), 542 + authserver_revocation_endpoint: None, 543 + scopes: Scopes::empty(), 544 + dpop_data: DpopClientData { 545 + dpop_key: jacquard_oauth::utils::generate_key(&[SmolStr::new_static("ES256")]) 546 + .unwrap(), 547 + dpop_authserver_nonce: SmolStr::default(), 548 + dpop_host_nonce: SmolStr::default(), 549 + }, 550 + token_set: jacquard_oauth::types::TokenSet { 551 + iss: SmolStr::new_static("https://issuer.example.com"), 552 + sub: account_did, 553 + aud: SmolStr::new_static("https://pds.example.com"), 554 + scope: None, 555 + refresh_token: None, 556 + access_token: SmolStr::new_static("access"), 557 + token_type: OAuthTokenType::DPoP, 558 + expires_at: None, 559 + }, 560 + #[cfg(feature = "scope-check")] 561 + resolved_scopes: None, 562 + } 563 + } 564 + 487 565 #[tokio::test] 488 566 async fn file_auth_store_roundtrip_atp() { 489 567 let path = temp_file(); ··· 495 573 refresh_jwt: "r".into(), 496 574 did: Did::new_static("did:plc:alice").unwrap(), 497 575 handle: Handle::new_static("alice.bsky.social").unwrap(), 576 + pds: None, 498 577 }; 499 - let key = SessionKey(session.did.clone(), "session".into()); 578 + let key = SessionKey::new(session.did.clone(), "session"); 500 579 jacquard_common::session::SessionStore::set(&store, key.clone(), session.clone()) 501 580 .await 502 581 .unwrap(); ··· 505 584 .unwrap(); 506 585 assert_eq!(restored.access_jwt.as_str(), "a"); 507 586 // clean up 587 + let _ = fs::remove_file(&path); 588 + } 589 + 590 + #[tokio::test] 591 + async fn file_auth_store_lists_only_atp_keys() { 592 + let path = temp_file(); 593 + fs::write(&path, "{}").unwrap(); 594 + let store = FileAuthStore::new(&path); 595 + let atp = AtpSession { 596 + access_jwt: "a".into(), 597 + refresh_jwt: "r".into(), 598 + did: Did::new_static("did:plc:alice").unwrap(), 599 + handle: Handle::new_static("alice.bsky.social").unwrap(), 600 + pds: None, 601 + }; 602 + let atp_key = SessionKey::new(atp.did.clone(), "session"); 603 + SessionStore::set(&store, atp_key.clone(), atp) 604 + .await 605 + .unwrap(); 606 + jacquard_oauth::authstore::ClientAuthStore::upsert_session( 607 + &store, 608 + oauth_session("did:plc:bob", "oauth-session"), 609 + ) 610 + .await 611 + .unwrap(); 612 + 613 + assert_eq!( 614 + SessionStore::list_keys(&store).await.unwrap(), 615 + vec![atp_key] 616 + ); 617 + let _ = fs::remove_file(&path); 618 + } 619 + 620 + #[tokio::test] 621 + async fn file_auth_store_lists_only_oauth_keys() { 622 + let path = temp_file(); 623 + fs::write(&path, "{}").unwrap(); 624 + let store = FileAuthStore::new(&path); 625 + let atp = AtpSession { 626 + access_jwt: "a".into(), 627 + refresh_jwt: "r".into(), 628 + did: Did::new_static("did:plc:alice").unwrap(), 629 + handle: Handle::new_static("alice.bsky.social").unwrap(), 630 + pds: None, 631 + }; 632 + SessionStore::set(&store, SessionKey::new(atp.did.clone(), "session"), atp) 633 + .await 634 + .unwrap(); 635 + jacquard_oauth::authstore::ClientAuthStore::upsert_session( 636 + &store, 637 + oauth_session("did:plc:bob", "oauth-session"), 638 + ) 639 + .await 640 + .unwrap(); 641 + 642 + assert_eq!( 643 + jacquard_oauth::authstore::ClientAuthStore::list_session_keys(&store) 644 + .await 645 + .unwrap(), 646 + vec![SessionKey::new( 647 + Did::new_static("did:plc:bob").unwrap(), 648 + "oauth-session", 649 + )] 650 + ); 508 651 let _ = fs::remove_file(&path); 509 652 } 510 653 }
+2 -1
crates/jacquard/tests/agent.rs
··· 97 97 refresh_jwt: "ref1".into(), 98 98 did: Did::new_static("did:plc:alice").unwrap(), 99 99 handle: Handle::new_static("alice.bsky.social").unwrap(), 100 + pds: None, 100 101 }; 101 - let key = SessionKey(atp.did.clone(), "session".into()); 102 + let key = SessionKey::new(atp.did.clone(), "session"); 102 103 jacquard_common::session::SessionStore::set(store.as_ref(), key.clone(), atp) 103 104 .await 104 105 .unwrap();
+188 -7
crates/jacquard/tests/credential_session.rs
··· 5 5 use http::{HeaderValue, Method, Response as HttpResponse, StatusCode}; 6 6 use jacquard::BosStr; 7 7 use jacquard::client::AtpSession; 8 - use jacquard::client::credential_session::{CredentialSession, SessionKey}; 8 + use jacquard::client::credential_session::{ 9 + CredentialResumeResult, CredentialSession, SessionKey, resolve_credential_session_hint, 10 + }; 11 + use jacquard::deps::fluent_uri::Uri; 9 12 use jacquard::identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions}; 10 13 use jacquard::types::did::Did; 11 14 use jacquard::types::string::Handle; 12 15 use jacquard::xrpc::XrpcClient; 13 16 use jacquard_common::http_client::HttpClient; 14 - use jacquard_common::session::{MemorySessionStore, SessionStore}; 15 - use smol_str::SmolStr; 17 + use jacquard_common::session::{MemorySessionStore, SessionHint, SessionStore}; 16 18 use tokio::sync::{Mutex, RwLock}; 17 19 18 20 #[derive(Clone, Default)] ··· 23 25 log: Arc<Mutex<Vec<http::Request<Vec<u8>>>>>, 24 26 // Count calls to identity resolver helpers 25 27 did_doc_calls: Arc<RwLock<usize>>, 28 + handle_calls: Arc<RwLock<usize>>, 26 29 } 27 30 28 31 impl MockClient { ··· 67 70 handle: &Handle<S>, 68 71 ) -> std::result::Result<Did, jacquard::identity::resolver::IdentityError> { 69 72 // Return a fixed DID for any handle 73 + *self.handle_calls.write().await += 1; 70 74 assert!(handle.as_str().contains('.')); 71 75 Ok(Did::new_static("did:plc:alice").unwrap()) 72 76 } ··· 125 129 .unwrap() 126 130 } 127 131 132 + fn atp_session(did: &'static str, handle: &'static str) -> AtpSession { 133 + AtpSession { 134 + access_jwt: "acc".into(), 135 + refresh_jwt: "ref".into(), 136 + did: Did::new_static(did).unwrap(), 137 + handle: Handle::new_static(handle).unwrap(), 138 + pds: None, 139 + } 140 + } 141 + 142 + #[tokio::test] 143 + async fn credential_session_hint_matcher_resolves_common_hints() { 144 + let store = MemorySessionStore::<SessionKey, AtpSession>::default(); 145 + let client = MockClient::default(); 146 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "session"); 147 + store 148 + .set( 149 + key.clone(), 150 + atp_session("did:plc:alice", "alice.bsky.social"), 151 + ) 152 + .await 153 + .unwrap(); 154 + 155 + let matched = resolve_credential_session_hint(&store, &client, &SessionHint::Any) 156 + .await 157 + .unwrap() 158 + .expect("any match"); 159 + assert_eq!(matched.key, key); 160 + assert_eq!(matched.session.handle.as_str(), "alice.bsky.social"); 161 + assert_eq!(matched.session.pds, None); 162 + 163 + let matched = resolve_credential_session_hint( 164 + &store, 165 + &client, 166 + &SessionHint::Did(Did::new_static("did:plc:alice").unwrap()), 167 + ) 168 + .await 169 + .unwrap() 170 + .expect("did match"); 171 + assert_eq!(matched.key, key); 172 + 173 + let matched = resolve_credential_session_hint( 174 + &store, 175 + &client, 176 + &SessionHint::Handle(Handle::new_static("alice.bsky.social").unwrap()), 177 + ) 178 + .await 179 + .unwrap() 180 + .expect("handle match"); 181 + assert_eq!(matched.key, key); 182 + 183 + let matched = resolve_credential_session_hint(&store, &client, &SessionHint::Key(key.clone())) 184 + .await 185 + .unwrap() 186 + .expect("key match"); 187 + assert_eq!(matched.key, key); 188 + 189 + let missing = resolve_credential_session_hint( 190 + &store, 191 + &client, 192 + &SessionHint::Key(SessionKey::new( 193 + Did::new_static("did:plc:bob").unwrap(), 194 + "session", 195 + )), 196 + ) 197 + .await 198 + .unwrap(); 199 + assert!(missing.is_none()); 200 + 201 + let identifier = resolve_credential_session_hint( 202 + &store, 203 + &client, 204 + &SessionHint::Identifier("alice@example.com".into()), 205 + ) 206 + .await 207 + .unwrap(); 208 + assert!( 209 + identifier.is_none(), 210 + "identifier hints must not fall back to Any" 211 + ); 212 + } 213 + 214 + #[tokio::test] 215 + async fn credential_resolver_selector_uses_store_before_handle_resolution() { 216 + let store = MemorySessionStore::<SessionKey, AtpSession>::default(); 217 + let client = MockClient::default(); 218 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "session"); 219 + store 220 + .set( 221 + key.clone(), 222 + atp_session("did:plc:alice", "alice.bsky.social"), 223 + ) 224 + .await 225 + .unwrap(); 226 + 227 + let matched = resolve_credential_session_hint( 228 + &store, 229 + &client, 230 + &SessionHint::Handle(Handle::new_static("alice.bsky.social").unwrap()), 231 + ) 232 + .await 233 + .unwrap() 234 + .expect("store-level handle match"); 235 + assert_eq!(matched.key, key); 236 + assert_eq!( 237 + *client.handle_calls.read().await, 238 + 0, 239 + "store selector should get first chance to satisfy handle hints" 240 + ); 241 + 242 + let matched = resolve_credential_session_hint( 243 + &store, 244 + &client, 245 + &SessionHint::Handle(Handle::new_static("alias.bsky.social").unwrap()), 246 + ) 247 + .await 248 + .unwrap() 249 + .expect("resolver fallback did match"); 250 + assert_eq!(matched.key, key); 251 + assert_eq!( 252 + *client.handle_calls.read().await, 253 + 1, 254 + "resolver should be used only after store selector misses the handle" 255 + ); 256 + } 257 + 258 + #[tokio::test] 259 + async fn credential_resume_returns_challenge_details_without_password() { 260 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 261 + let client = Arc::new(MockClient::default()); 262 + let session = CredentialSession::new(store, client); 263 + 264 + let result = session.resume(&SessionHint::Any).await.expect("resume any"); 265 + let CredentialResumeResult::LoginRequired(challenge) = result else { 266 + panic!("expected login challenge for empty store"); 267 + }; 268 + assert_eq!(challenge.identifier, None); 269 + assert_eq!(challenge.session_id, None); 270 + 271 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "mobile"); 272 + let result = session 273 + .resume(&SessionHint::Key(key.clone())) 274 + .await 275 + .expect("resume key"); 276 + let CredentialResumeResult::LoginRequired(challenge) = result else { 277 + panic!("expected login challenge for missing key"); 278 + }; 279 + assert_eq!(challenge.identifier.as_deref(), Some(key.did.as_str())); 280 + assert_eq!(challenge.session_id.as_deref(), Some("mobile")); 281 + 282 + let result = session 283 + .resume(&SessionHint::Identifier("alice@example.com".into())) 284 + .await 285 + .expect("resume identifier"); 286 + let CredentialResumeResult::LoginRequired(challenge) = result else { 287 + panic!("expected login challenge for identifier"); 288 + }; 289 + assert_eq!(challenge.identifier.as_deref(), Some("alice@example.com")); 290 + assert_eq!(challenge.session_id, None); 291 + } 292 + 293 + #[tokio::test] 294 + async fn credential_resume_uses_stored_pds_without_resolving_did_doc() { 295 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 296 + let client = Arc::new(MockClient::default()); 297 + let session = CredentialSession::new(store.clone(), client.clone()); 298 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "session"); 299 + let mut stored = atp_session("did:plc:alice", "alice.bsky.social"); 300 + stored.pds = Some(Uri::parse("https://stored-pds").unwrap().to_owned()); 301 + store.set(key.clone(), stored.clone()).await.unwrap(); 302 + 303 + let result = session.resume(&SessionHint::Any).await.expect("resume any"); 304 + let CredentialResumeResult::Resumed(resumed) = result else { 305 + panic!("expected resumed session"); 306 + }; 307 + assert_eq!(resumed.pds.as_ref().unwrap().as_str(), "https://stored-pds"); 308 + assert_eq!(session.endpoint().await.as_str(), "https://stored-pds"); 309 + assert_eq!(*client.did_doc_calls.read().await, 0); 310 + } 311 + 128 312 #[tokio::test(flavor = "multi_thread")] 129 313 async fn credential_login_and_auto_refresh() { 130 314 let client = Arc::new(MockClient::default()); ··· 255 439 ); 256 440 257 441 // Verify store updated with refreshed tokens 258 - let key = SessionKey( 259 - Did::new_static("did:plc:alice").unwrap(), 260 - SmolStr::from("session"), 261 - ); 442 + let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "session"); 262 443 let updated = store.get(&key).await.expect("session present"); 263 444 assert_eq!(updated.access_jwt.as_str(), "acc2"); 264 445 assert_eq!(updated.refresh_jwt.as_str(), "ref2");
+4 -4
crates/jacquard/tests/oauth_auto_refresh.rs
··· 16 16 use jacquard_oauth::session::SessionRegistry; 17 17 use jacquard_oauth::session::{ClientData, ClientSessionData, DpopClientData}; 18 18 use jacquard_oauth::types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet}; 19 - use smol_str::SmolStr; 19 + use smol_str::{SmolStr, format_smolstr}; 20 20 use tokio::sync::Mutex; 21 21 22 22 #[derive(Clone, Default)] ··· 91 91 // Return minimal metadata with supported auth method "none" and DPoP support 92 92 let mut md = OAuthAuthorizationServerMetadata::default(); 93 93 md.issuer = SmolStr::from(issuer); 94 - md.token_endpoint = SmolStr::from(format!("{}/token", issuer)); 95 - md.authorization_endpoint = SmolStr::from(format!("{}/authorize", issuer)); 94 + md.token_endpoint = format_smolstr!("{}/token", issuer); 95 + md.authorization_endpoint = format_smolstr!("{}/authorize", issuer); 96 96 md.require_pushed_authorization_requests = Some(true); 97 - md.pushed_authorization_request_endpoint = Some(SmolStr::from(format!("{}/par", issuer))); 97 + md.pushed_authorization_request_endpoint = Some(format_smolstr!("{}/par", issuer)); 98 98 md.token_endpoint_auth_methods_supported = Some(vec![SmolStr::from("none")]); 99 99 md.dpop_signing_alg_values_supported = Some(vec![SmolStr::from("ES256")]); 100 100 Ok(md)
+42 -4
crates/jacquard/tests/oauth_flow.rs
··· 7 7 use jacquard::client::Agent; 8 8 use jacquard::xrpc::XrpcClient; 9 9 use jacquard_common::http_client::HttpClient; 10 + use jacquard_common::session::SessionHint; 10 11 use jacquard_oauth::atproto::AtprotoClientMetadata; 11 12 use jacquard_oauth::authstore::ClientAuthStore; 12 13 use jacquard_oauth::client::OAuthClient; 13 14 use jacquard_oauth::resolver::OAuthResolver; 14 15 use jacquard_oauth::scopes::Scopes; 15 16 use jacquard_oauth::session::ClientData; 16 - use smol_str::SmolStr; 17 + use smol_str::{SmolStr, format_smolstr}; 17 18 18 19 #[derive(Clone, Default)] 19 20 struct MockClient { ··· 120 121 > { 121 122 let mut md = jacquard_oauth::types::OAuthAuthorizationServerMetadata::default(); 122 123 md.issuer = SmolStr::from(issuer); 123 - md.authorization_endpoint = SmolStr::from(format!("{}/authorize", issuer)); 124 - md.token_endpoint = SmolStr::from(format!("{}/token", issuer)); 124 + md.authorization_endpoint = format_smolstr!("{}/authorize", issuer); 125 + md.token_endpoint = format_smolstr!("{}/token", issuer); 125 126 md.require_pushed_authorization_requests = Some(true); 126 - md.pushed_authorization_request_endpoint = Some(SmolStr::from(format!("{}/par", issuer))); 127 + md.pushed_authorization_request_endpoint = Some(format_smolstr!("{}/par", issuer)); 127 128 md.token_endpoint_auth_methods_supported = Some(vec![SmolStr::from("none")]); 128 129 md.dpop_signing_alg_values_supported = Some(vec![SmolStr::from("ES256")]); 129 130 Ok(md) ··· 315 316 316 317 let _ = std::fs::remove_file(&path); 317 318 } 319 + 320 + #[tokio::test] 321 + async fn oauth_resume_or_start_auth_rejects_any_without_identity() { 322 + let client = MockClient::default(); 323 + let store = jacquard::client::FileAuthStore::new({ 324 + let mut path = std::env::temp_dir(); 325 + path.push(format!( 326 + "jacquard-oauth-any-reject-{}.json", 327 + std::process::id() 328 + )); 329 + std::fs::write(&path, "{}").unwrap(); 330 + path 331 + }); 332 + let oauth = OAuthClient::new_from_resolver( 333 + store, 334 + client, 335 + ClientData { 336 + keyset: None, 337 + config: AtprotoClientMetadata::new_localhost( 338 + None, 339 + Some(Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap()), 340 + ), 341 + }, 342 + ); 343 + 344 + let err = match oauth 345 + .resume_or_start_auth( 346 + &SessionHint::Any, 347 + jacquard_oauth::types::AuthorizeOptions::<SmolStr>::default(), 348 + ) 349 + .await 350 + { 351 + Ok(_) => panic!("Any cannot start auth without identity"), 352 + Err(err) => err, 353 + }; 354 + assert!(err.to_string().contains("cannot start OAuth authorization")); 355 + }
+6 -20
crates/jacquard/tests/restore_pds_cache.rs
··· 91 91 refresh_jwt: "ref".into(), 92 92 did: Did::new_static("did:plc:alice").unwrap(), 93 93 handle: Handle::new_static("alice.bsky.social").unwrap(), 94 + pds: Some( 95 + Uri::parse("https://pds-cached") 96 + .expect("valid uri") 97 + .to_owned(), 98 + ), 94 99 }; 95 - let key = SessionKey(session.did.clone(), "session".into()); 100 + let key = SessionKey::new(session.did.clone(), "session"); 96 101 jacquard_common::session::SessionStore::set(store.as_ref(), key.clone(), session) 97 102 .await 98 103 .unwrap(); 99 104 // Verify it is persisted 100 105 assert!(SessionStore::get(store.as_ref(), &key).await.is_some()); 101 - // Persist PDS endpoint cache to avoid DID resolution on restore 102 - store 103 - .set_atp_pds( 104 - &key, 105 - &Uri::parse("https://pds-cached") 106 - .expect("valid uri") 107 - .to_owned(), 108 - ) 109 - .unwrap(); 110 - assert_eq!( 111 - store 112 - .get_atp_pds(&key) 113 - .ok() 114 - .flatten() 115 - .expect("pds cached") 116 - .as_str(), 117 - "https://pds-cached" 118 - ); 119 - 120 106 let session = CredentialSession::new(store.clone(), resolver.clone()); 121 107 // Restore should pick cached PDS and NOT call resolve_did_doc 122 108 session
+4 -4
crates/jacquard/tests/scope_check.rs
··· 21 21 use jacquard_oauth::session::SessionRegistry; 22 22 use jacquard_oauth::session::{ClientData, ClientSessionData, DpopClientData}; 23 23 use jacquard_oauth::types::{OAuthAuthorizationServerMetadata, OAuthTokenType, TokenSet}; 24 - use smol_str::SmolStr; 24 + use smol_str::{SmolStr, format_smolstr}; 25 25 use std::collections::BTreeSet; 26 26 use tokio::sync::Mutex; 27 27 ··· 97 97 ) -> Result<OAuthAuthorizationServerMetadata, jacquard_oauth::resolver::ResolverError> { 98 98 let mut md = OAuthAuthorizationServerMetadata::default(); 99 99 md.issuer = SmolStr::from(issuer); 100 - md.token_endpoint = SmolStr::from(format!("{}/token", issuer)); 101 - md.authorization_endpoint = SmolStr::from(format!("{}/authorize", issuer)); 100 + md.token_endpoint = format_smolstr!("{}/token", issuer); 101 + md.authorization_endpoint = format_smolstr!("{}/authorize", issuer); 102 102 md.require_pushed_authorization_requests = Some(true); 103 - md.pushed_authorization_request_endpoint = Some(SmolStr::from(format!("{}/par", issuer))); 103 + md.pushed_authorization_request_endpoint = Some(format_smolstr!("{}/par", issuer)); 104 104 md.token_endpoint_auth_methods_supported = Some(vec![SmolStr::from("none")]); 105 105 md.dpop_signing_alg_values_supported = Some(vec![SmolStr::from("ES256")]); 106 106 Ok(md)