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