From 44da84f99febbbdc56a65b9cbf8a31dbd89fafaf Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 10 Nov 2025 16:17:19 -0500 Subject: [PATCH] various improvements to oauth stuff --- crates/jacquard-identity/src/resolver.rs | 17 ++- crates/jacquard-oauth/src/atproto.rs | 59 +++++++++ crates/jacquard-oauth/src/client.rs | 118 +++++++++++++++++- crates/jacquard-oauth/src/request.rs | 10 ++ crates/jacquard-oauth/src/session.rs | 13 ++ crates/jacquard-oauth/src/types.rs | 35 ++++++ .../src/types/client_metadata.rs | 13 ++ crates/jacquard/src/client.rs | 95 +++++++++++++- 8 files changed, 349 insertions(+), 11 deletions(-) diff --git a/crates/jacquard-identity/src/resolver.rs b/crates/jacquard-identity/src/resolver.rs index d63a7565c..81a4a93b9 100644 --- a/crates/jacquard-identity/src/resolver.rs +++ b/crates/jacquard-identity/src/resolver.rs @@ -228,17 +228,24 @@ impl Default for ResolverOptions { let mut handle_order = vec![]; #[cfg(not(target_family = "wasm"))] handle_order.push(HandleStep::DnsTxt); + #[cfg(not(target_family = "wasm"))] handle_order.push(HandleStep::HttpsWellKnown); handle_order.push(HandleStep::PdsResolveHandle); + #[cfg(target_family = "wasm")] + handle_order.push(HandleStep::HttpsWellKnown); + + let mut did_order = vec![]; + #[cfg(not(target_family = "wasm"))] + did_order.push(DidStep::DidWebHttps); + did_order.push(DidStep::PlcHttp); + did_order.push(DidStep::PdsResolveDid); + #[cfg(target_family = "wasm")] + did_order.push(DidStep::DidWebHttps); Self::new() .plc_source(PlcSource::default()) .handle_order(handle_order) - .did_order(vec![ - DidStep::DidWebHttps, - DidStep::PlcHttp, - DidStep::PdsResolveDid, - ]) + .did_order(did_order) .validate_doc_id(true) .public_fallback_for_handle(true) .build() diff --git a/crates/jacquard-oauth/src/atproto.rs b/crates/jacquard-oauth/src/atproto.rs index e51d1e4dc..3583f1122 100644 --- a/crates/jacquard-oauth/src/atproto.rs +++ b/crates/jacquard-oauth/src/atproto.rs @@ -4,6 +4,7 @@ use crate::types::OAuthClientMetadata; use crate::{keyset::Keyset, scopes::Scope}; use jacquard_common::CowStr; use serde::{Deserialize, Serialize}; +use smol_str::{SmolStr, ToSmolStr}; use thiserror::Error; use url::Url; @@ -85,6 +86,10 @@ pub struct AtprotoClientMetadata<'m> { #[serde(borrow)] pub scopes: Vec>, pub jwks_uri: Option, + pub client_name: Option, + pub logo_uri: Option, + pub tos_uri: Option, + pub privacy_policy_uri: Option, } impl<'m> AtprotoClientMetadata<'m> { @@ -103,9 +108,27 @@ impl<'m> AtprotoClientMetadata<'m> { grant_types, scopes, jwks_uri, + client_name: None, + logo_uri: None, + tos_uri: None, + privacy_policy_uri: None, } } + pub fn with_prod_info( + mut self, + client_name: &str, + logo_uri: Option, + tos_uri: Option, + privacy_policy_uri: Option, + ) -> Self { + self.client_name = Some(client_name.to_smolstr()); + self.logo_uri = logo_uri; + self.tos_uri = tos_uri; + self.privacy_policy_uri = privacy_policy_uri; + self + } + pub fn default_localhost() -> Self { Self::new_localhost( None, @@ -155,6 +178,10 @@ impl<'m> AtprotoClientMetadata<'m> { grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], scopes: scopes.unwrap_or(vec![Scope::Atproto]), jwks_uri: None, + client_name: None, + logo_uri: None, + tos_uri: None, + privacy_policy_uri: None, } } } @@ -208,6 +235,10 @@ pub fn atproto_client_metadata<'m>( } else { None }, + client_name: metadata.client_name, + logo_uri: metadata.logo_uri, + tos_uri: metadata.tos_uri, + privacy_policy_uri: metadata.privacy_policy_uri, }) } @@ -247,6 +278,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 jwks_uri: None, jwks: None, token_endpoint_auth_signing_alg: None, + tos_uri: None, + privacy_policy_uri: None, + client_name: None, + logo_uri: None, } ); } @@ -285,6 +320,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 jwks_uri: None, jwks: None, token_endpoint_auth_signing_alg: None, + tos_uri: None, + privacy_policy_uri: None, + client_name: None, + logo_uri: None, } ); } @@ -317,6 +356,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 jwks_uri: None, jwks: None, token_endpoint_auth_signing_alg: None, + tos_uri: None, + privacy_policy_uri: None, + client_name: None, + logo_uri: None, } ); } @@ -345,6 +388,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 jwks_uri: None, jwks: None, token_endpoint_auth_signing_alg: None, + tos_uri: None, + privacy_policy_uri: None, + client_name: None, + logo_uri: None, } ); } @@ -373,6 +420,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 jwks_uri: None, jwks: None, token_endpoint_auth_signing_alg: None, + tos_uri: None, + privacy_policy_uri: None, + client_name: None, + logo_uri: None, } ); } @@ -387,6 +438,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 grant_types: vec![GrantType::AuthorizationCode], scopes: vec![Scope::Atproto], jwks_uri: None, + client_name: None, + logo_uri: None, + tos_uri: None, + privacy_policy_uri: None, }; { // Non-loopback clients without a keyset should fail (must provide JWKS) @@ -420,6 +475,10 @@ gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 jwks_uri: None, jwks: Some(keyset.public_jwks()), token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), + client_name: None, + logo_uri: None, + tos_uri: None, + privacy_policy_uri: None, } ); } diff --git a/crates/jacquard-oauth/src/client.rs b/crates/jacquard-oauth/src/client.rs index 58031eb6f..df353252b 100644 --- a/crates/jacquard-oauth/src/client.rs +++ b/crates/jacquard-oauth/src/client.rs @@ -39,6 +39,8 @@ where S: ClientAuthStore, { pub registry: Arc>, + pub options: RwLock>, + pub endpoint: RwLock>, pub client: Arc, } @@ -106,12 +108,17 @@ where redirect_uris = ?client_data.config.redirect_uris, scopes = ?client_data.config.scopes, has_keyset = client_data.keyset.is_some(), - "oauth client created" + "oauth client created:" ); let client = Arc::new(client); let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); - Self { registry, client } + Self { + registry, + client, + options: RwLock::new(CallOptions::default()), + endpoint: RwLock::new(None), + } } pub fn new_with_shared( @@ -124,7 +131,12 @@ where client.clone(), client_data, )); - Self { registry, client } + Self { + registry, + client, + options: RwLock::new(CallOptions::default()), + endpoint: RwLock::new(None), + } } } @@ -151,7 +163,6 @@ where self.registry.client_data.config.clone(), &self.registry.client_data.keyset, )?; - let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?; let login_hint = if identity.is_some() { Some(input.as_ref().into()) @@ -163,8 +174,10 @@ where client_metadata, keyset: self.registry.client_data.keyset.clone(), }; + let auth_req_info = par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?; + // Persist state for callback handling self.registry .store @@ -284,6 +297,97 @@ where } } +impl HttpClient for OAuthClient +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 IdentityResolver for OAuthClient +where + S: ClientAuthStore + Send + Sync + 'static, + T: OAuthResolver + DpopExt + Send + Sync + 'static, +{ + fn options(&self) -> &ResolverOptions { + self.client.options() + } + + async fn resolve_handle( + &self, + handle: &Handle<'_>, + ) -> jacquard_identity::resolver::Result> { + self.client.resolve_handle(handle).await + } + + async fn resolve_did_doc( + &self, + did: &Did<'_>, + ) -> jacquard_identity::resolver::Result { + self.client.resolve_did_doc(did).await + } +} + +impl XrpcClient for OAuthClient +where + S: ClientAuthStore + Send + Sync + 'static, + T: OAuthResolver + DpopExt + Send + Sync + 'static, +{ + async fn base_uri(&self) -> Url { + self.endpoint.read().await.clone().unwrap_or( + Url::parse("https://public.api.bsky.app").expect("public appview should be valid url"), + ) + } + + async fn opts(&self) -> CallOptions<'_> { + self.options.read().await.clone() + } + + async fn set_opts(&self, opts: CallOptions<'_>) { + let mut guard = self.options.write().await; + *guard = opts.into_static(); + } + + async fn set_base_uri(&self, url: Url) { + let mut guard = self.endpoint.write().await; + *guard = Some(url); + } + + async fn send(&self, request: R) -> XrpcResult> + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + { + let opts = self.options.read().await.clone(); + self.send_with_opts(request, opts).await + } + + async fn send_with_opts( + &self, + request: R, + opts: CallOptions<'_>, + ) -> XrpcResult> + where + R: XrpcRequest + Send + Sync, + ::Response: Send + Sync, + { + let base_uri = self.base_uri().await; + self.client + .xrpc(base_uri.clone()) + .with_options(opts.clone()) + .send(&request) + .await + } +} + pub struct OAuthSession where T: OAuthResolver, @@ -377,6 +481,10 @@ where .as_ref() .map(|t| AuthorizationToken::Dpop(t.clone())) } + + pub fn to_client(&self) -> OAuthClient { + OAuthClient::from_session(self) + } } impl OAuthSession where @@ -411,6 +519,8 @@ where Self { registry: session.registry.clone(), client: session.client.clone(), + options: RwLock::new(CallOptions::default()), + endpoint: RwLock::new(None), } } } diff --git a/crates/jacquard-oauth/src/request.rs b/crates/jacquard-oauth/src/request.rs index b01f80112..0edc4afb2 100644 --- a/crates/jacquard-oauth/src/request.rs +++ b/crates/jacquard-oauth/src/request.rs @@ -495,6 +495,12 @@ pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( login_hint: login_hint, prompt: prompt.map(CowStr::from), }; + + #[cfg(feature = "tracing")] + tracing::debug!( + parameters = ?parameters, + "par:" + ); if metadata .server_metadata .pushed_authorization_request_endpoint @@ -937,6 +943,10 @@ mod tests { jwks_uri: None, jwks: None, token_endpoint_auth_signing_alg: None, + client_name: None, + privacy_policy_uri: None, + tos_uri: None, + logo_uri: None, }, keyset: None, } diff --git a/crates/jacquard-oauth/src/session.rs b/crates/jacquard-oauth/src/session.rs index 69e0f34b8..44cce5f6d 100644 --- a/crates/jacquard-oauth/src/session.rs +++ b/crates/jacquard-oauth/src/session.rs @@ -237,6 +237,19 @@ pub struct ClientData<'s> { pub config: AtprotoClientMetadata<'s>, } +impl<'s> ClientData<'s> { + pub fn new(keyset: Option, config: AtprotoClientMetadata<'s>) -> Self { + Self { keyset, config } + } + + pub fn new_public(config: AtprotoClientMetadata<'s>) -> Self { + Self { + keyset: None, + config, + } + } +} + pub struct ClientSession<'s> { pub keyset: Option, pub config: AtprotoClientMetadata<'s>, diff --git a/crates/jacquard-oauth/src/types.rs b/crates/jacquard-oauth/src/types.rs index caa529e2c..6eff91c8d 100644 --- a/crates/jacquard-oauth/src/types.rs +++ b/crates/jacquard-oauth/src/types.rs @@ -12,6 +12,7 @@ pub use self::request::*; pub use self::response::*; pub use self::token::*; use jacquard_common::CowStr; +use jacquard_common::IntoStatic; use serde::Deserialize; use url::Url; @@ -53,6 +54,28 @@ impl Default for AuthorizeOptions<'_> { } } +impl<'s> AuthorizeOptions<'s> { + pub fn with_prompt(mut self, prompt: AuthorizeOptionPrompt) -> Self { + self.prompt = Some(prompt); + self + } + + pub fn with_state(mut self, state: CowStr<'s>) -> Self { + self.state = Some(state); + self + } + + pub fn with_redirect_uri(mut self, redirect_uri: Url) -> Self { + self.redirect_uri = Some(redirect_uri); + self + } + + pub fn with_scopes(mut self, scopes: Vec>) -> Self { + self.scopes = scopes; + self + } +} + #[derive(Debug, Deserialize)] pub struct CallbackParams<'s> { #[serde(borrow)] @@ -60,3 +83,15 @@ pub struct CallbackParams<'s> { pub state: Option>, pub iss: Option>, } + +impl IntoStatic for CallbackParams<'_> { + type Output = CallbackParams<'static>; + + fn into_static(self) -> Self::Output { + CallbackParams { + code: self.code.into_static(), + state: self.state.map(|s| s.into_static()), + iss: self.iss.map(|s| s.into_static()), + } + } +} diff --git a/crates/jacquard-oauth/src/types/client_metadata.rs b/crates/jacquard-oauth/src/types/client_metadata.rs index c0cad7e86..5f8c11840 100644 --- a/crates/jacquard-oauth/src/types/client_metadata.rs +++ b/crates/jacquard-oauth/src/types/client_metadata.rs @@ -1,6 +1,7 @@ use jacquard_common::{CowStr, IntoStatic}; use jose_jwk::JwkSet; use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; use url::Url; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -27,6 +28,14 @@ pub struct OAuthClientMetadata<'c> { // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata #[serde(skip_serializing_if = "Option::is_none")] pub token_endpoint_auth_signing_alg: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub privacy_policy_uri: Option, } impl OAuthClientMetadata<'_> {} @@ -50,6 +59,10 @@ impl IntoStatic for OAuthClientMetadata<'_> { token_endpoint_auth_signing_alg: self .token_endpoint_auth_signing_alg .map(|alg| alg.into_static()), + client_name: self.client_name, + logo_uri: self.logo_uri, + tos_uri: self.tos_uri, + privacy_policy_uri: self.privacy_policy_uri, } } } diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index 9dbd13322..cfffa8d4e 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -30,6 +30,8 @@ use crate::client::vec_update::VecUpdate; use core::future::Future; pub use error::*; #[cfg(feature = "api")] +use jacquard_api::com_atproto::repo::get_record::GetRecordOutput; +#[cfg(feature = "api")] use jacquard_api::com_atproto::{ repo::{ create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, @@ -47,7 +49,6 @@ use jacquard_common::types::recordkey::{RecordKey, Rkey}; use jacquard_common::types::string::AtUri; #[cfg(feature = "api")] use jacquard_common::types::uri::RecordUri; -#[cfg(not(target_arch = "wasm32"))] use jacquard_common::xrpc::XrpcResponse; use jacquard_common::xrpc::{ CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest, XrpcResp, @@ -62,7 +63,7 @@ use jacquard_identity::resolver::{ }; use jacquard_identity::{JacquardResolver, slingshot_resolver_default}; use jacquard_oauth::authstore::ClientAuthStore; -use jacquard_oauth::client::OAuthSession; +use jacquard_oauth::client::{OAuthClient, OAuthSession}; use jacquard_oauth::dpop::DpopExt; use jacquard_oauth::resolver::OAuthResolver; use serde::Serialize; @@ -506,6 +507,10 @@ impl Agent { Self { inner } } + pub fn inner(&self) -> &A { + &self.inner + } + /// Return the underlying session kind. pub fn kind(&self) -> AgentKind { self.inner.session_kind() @@ -760,6 +765,64 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { } } + /// Untyped, freeform record fetcher. + /// Hits [https://slingshot.microcosm.blue] + fn fetch_record_slingshot( + &self, + uri: &AtUri<'_>, + ) -> impl Future>> { + async move { + #[cfg(feature = "tracing")] + let _span = tracing::debug_span!("fetch_record_slingshot", uri = %uri).entered(); + + // Make stateless XRPC call to that PDS (no auth required for public records) + use jacquard_api::com_atproto::repo::get_record::GetRecord; + let collection = uri.collection().clone().ok_or(AgentError::sub_operation( + "no collection", + ClientError::invalid_request("no collection"), + ))?; + let rkey = uri.rkey().ok_or(AgentError::sub_operation( + "no rkey", + ClientError::invalid_request("no rkey"), + ))?; + let request = GetRecord::new() + .repo(uri.authority().clone()) + .collection(collection.clone()) + .rkey(rkey.clone()) + .build(); + + let response: Response = { + use url::Url; + + let http_request = xrpc::build_http_request( + &Url::parse("https://slingshot.microcosm.blue") + .expect("slingshot url is valid"), + &request, + &self.opts().await, + )?; + + let http_response = self + .send_http(http_request) + .await + .map_err(|e| ClientError::transport(e))?; + + xrpc::process_response(http_response) + }?; + let output = response.into_output().map_err(|e| match e { + XrpcError::Auth(auth) => AgentError::from(auth), + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), + XrpcError::Xrpc(typed) => AgentError::new( + AgentErrorKind::SubOperation { + step: "fetch record", + }, + None, + ) + .with_details(typed.to_string()), + })?; + Ok(output) + } + } + /// Fetches a record from the PDS. Returns an owned, parsed response. /// /// Takes an at:// URI annotated with the collection type, which be constructed with `R::uri(uri)` @@ -1169,6 +1232,34 @@ where } } +impl AgentSession for OAuthClient +where + S: ClientAuthStore + Send + Sync + 'static, + T: OAuthResolver + DpopExt + Send + Sync + 'static, +{ + fn session_kind(&self) -> AgentKind { + AgentKind::OAuth + } + fn session_info( + &self, + ) -> impl Future, Option>)>> { + async { None } + } + fn endpoint(&self) -> impl Future { + async { self.base_uri().await } + } + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future { + async { self.set_opts(opts).await } + } + fn refresh(&self) -> impl Future>> { + async { + Err(ClientError::auth( + jacquard_common::error::AuthError::NotAuthenticated, + )) + } + } +} + impl HttpClient for Agent { type Error = ::Error; -- 2.51.2