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