A better Rust ATProto crate

various improvements to oauth stuff

Orual 44da84f9 61b91cf5

Changed files
+349 -11
crates
jacquard
src
jacquard-identity
jacquard-oauth
+12 -5
crates/jacquard-identity/src/resolver.rs
··· 228 228 let mut handle_order = vec![]; 229 229 #[cfg(not(target_family = "wasm"))] 230 230 handle_order.push(HandleStep::DnsTxt); 231 + #[cfg(not(target_family = "wasm"))] 231 232 handle_order.push(HandleStep::HttpsWellKnown); 232 233 handle_order.push(HandleStep::PdsResolveHandle); 234 + #[cfg(target_family = "wasm")] 235 + handle_order.push(HandleStep::HttpsWellKnown); 236 + 237 + let mut did_order = vec![]; 238 + #[cfg(not(target_family = "wasm"))] 239 + did_order.push(DidStep::DidWebHttps); 240 + did_order.push(DidStep::PlcHttp); 241 + did_order.push(DidStep::PdsResolveDid); 242 + #[cfg(target_family = "wasm")] 243 + did_order.push(DidStep::DidWebHttps); 233 244 234 245 Self::new() 235 246 .plc_source(PlcSource::default()) 236 247 .handle_order(handle_order) 237 - .did_order(vec![ 238 - DidStep::DidWebHttps, 239 - DidStep::PlcHttp, 240 - DidStep::PdsResolveDid, 241 - ]) 248 + .did_order(did_order) 242 249 .validate_doc_id(true) 243 250 .public_fallback_for_handle(true) 244 251 .build()
+59
crates/jacquard-oauth/src/atproto.rs
··· 4 4 use crate::{keyset::Keyset, scopes::Scope}; 5 5 use jacquard_common::CowStr; 6 6 use serde::{Deserialize, Serialize}; 7 + use smol_str::{SmolStr, ToSmolStr}; 7 8 use thiserror::Error; 8 9 use url::Url; 9 10 ··· 85 86 #[serde(borrow)] 86 87 pub scopes: Vec<Scope<'m>>, 87 88 pub jwks_uri: Option<Url>, 89 + pub client_name: Option<SmolStr>, 90 + pub logo_uri: Option<Url>, 91 + pub tos_uri: Option<Url>, 92 + pub privacy_policy_uri: Option<Url>, 88 93 } 89 94 90 95 impl<'m> AtprotoClientMetadata<'m> { ··· 103 108 grant_types, 104 109 scopes, 105 110 jwks_uri, 111 + client_name: None, 112 + logo_uri: None, 113 + tos_uri: None, 114 + privacy_policy_uri: None, 106 115 } 107 116 } 108 117 118 + pub fn with_prod_info( 119 + mut self, 120 + client_name: &str, 121 + logo_uri: Option<Url>, 122 + tos_uri: Option<Url>, 123 + privacy_policy_uri: Option<Url>, 124 + ) -> Self { 125 + self.client_name = Some(client_name.to_smolstr()); 126 + self.logo_uri = logo_uri; 127 + self.tos_uri = tos_uri; 128 + self.privacy_policy_uri = privacy_policy_uri; 129 + self 130 + } 131 + 109 132 pub fn default_localhost() -> Self { 110 133 Self::new_localhost( 111 134 None, ··· 155 178 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 156 179 scopes: scopes.unwrap_or(vec![Scope::Atproto]), 157 180 jwks_uri: None, 181 + client_name: None, 182 + logo_uri: None, 183 + tos_uri: None, 184 + privacy_policy_uri: None, 158 185 } 159 186 } 160 187 } ··· 208 235 } else { 209 236 None 210 237 }, 238 + client_name: metadata.client_name, 239 + logo_uri: metadata.logo_uri, 240 + tos_uri: metadata.tos_uri, 241 + privacy_policy_uri: metadata.privacy_policy_uri, 211 242 }) 212 243 } 213 244 ··· 247 278 jwks_uri: None, 248 279 jwks: None, 249 280 token_endpoint_auth_signing_alg: None, 281 + tos_uri: None, 282 + privacy_policy_uri: None, 283 + client_name: None, 284 + logo_uri: None, 250 285 } 251 286 ); 252 287 } ··· 285 320 jwks_uri: None, 286 321 jwks: None, 287 322 token_endpoint_auth_signing_alg: None, 323 + tos_uri: None, 324 + privacy_policy_uri: None, 325 + client_name: None, 326 + logo_uri: None, 288 327 } 289 328 ); 290 329 } ··· 317 356 jwks_uri: None, 318 357 jwks: None, 319 358 token_endpoint_auth_signing_alg: None, 359 + tos_uri: None, 360 + privacy_policy_uri: None, 361 + client_name: None, 362 + logo_uri: None, 320 363 } 321 364 ); 322 365 } ··· 345 388 jwks_uri: None, 346 389 jwks: None, 347 390 token_endpoint_auth_signing_alg: None, 391 + tos_uri: None, 392 + privacy_policy_uri: None, 393 + client_name: None, 394 + logo_uri: None, 348 395 } 349 396 ); 350 397 } ··· 373 420 jwks_uri: None, 374 421 jwks: None, 375 422 token_endpoint_auth_signing_alg: None, 423 + tos_uri: None, 424 + privacy_policy_uri: None, 425 + client_name: None, 426 + logo_uri: None, 376 427 } 377 428 ); 378 429 } ··· 387 438 grant_types: vec![GrantType::AuthorizationCode], 388 439 scopes: vec![Scope::Atproto], 389 440 jwks_uri: None, 441 + client_name: None, 442 + logo_uri: None, 443 + tos_uri: None, 444 + privacy_policy_uri: None, 390 445 }; 391 446 { 392 447 // Non-loopback clients without a keyset should fail (must provide JWKS) ··· 420 475 jwks_uri: None, 421 476 jwks: Some(keyset.public_jwks()), 422 477 token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), 478 + client_name: None, 479 + logo_uri: None, 480 + tos_uri: None, 481 + privacy_policy_uri: None, 423 482 } 424 483 ); 425 484 }
+114 -4
crates/jacquard-oauth/src/client.rs
··· 39 39 S: ClientAuthStore, 40 40 { 41 41 pub registry: Arc<SessionRegistry<T, S>>, 42 + pub options: RwLock<CallOptions<'static>>, 43 + pub endpoint: RwLock<Option<Url>>, 42 44 pub client: Arc<T>, 43 45 } 44 46 ··· 106 108 redirect_uris = ?client_data.config.redirect_uris, 107 109 scopes = ?client_data.config.scopes, 108 110 has_keyset = client_data.keyset.is_some(), 109 - "oauth client created" 111 + "oauth client created:" 110 112 ); 111 113 112 114 let client = Arc::new(client); 113 115 let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); 114 - Self { registry, client } 116 + Self { 117 + registry, 118 + client, 119 + options: RwLock::new(CallOptions::default()), 120 + endpoint: RwLock::new(None), 121 + } 115 122 } 116 123 117 124 pub fn new_with_shared( ··· 124 131 client.clone(), 125 132 client_data, 126 133 )); 127 - Self { registry, client } 134 + Self { 135 + registry, 136 + client, 137 + options: RwLock::new(CallOptions::default()), 138 + endpoint: RwLock::new(None), 139 + } 128 140 } 129 141 } 130 142 ··· 151 163 self.registry.client_data.config.clone(), 152 164 &self.registry.client_data.keyset, 153 165 )?; 154 - 155 166 let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?; 156 167 let login_hint = if identity.is_some() { 157 168 Some(input.as_ref().into()) ··· 163 174 client_metadata, 164 175 keyset: self.registry.client_data.keyset.clone(), 165 176 }; 177 + 166 178 let auth_req_info = 167 179 par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?; 180 + 168 181 // Persist state for callback handling 169 182 self.registry 170 183 .store ··· 284 297 } 285 298 } 286 299 300 + impl<T, S> HttpClient for OAuthClient<T, S> 301 + where 302 + S: ClientAuthStore + Send + Sync + 'static, 303 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 304 + { 305 + type Error = T::Error; 306 + 307 + async fn send_http( 308 + &self, 309 + request: http::Request<Vec<u8>>, 310 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 311 + self.client.send_http(request).await 312 + } 313 + } 314 + 315 + impl<T, S> IdentityResolver for OAuthClient<T, S> 316 + where 317 + S: ClientAuthStore + Send + Sync + 'static, 318 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 319 + { 320 + fn options(&self) -> &ResolverOptions { 321 + self.client.options() 322 + } 323 + 324 + async fn resolve_handle( 325 + &self, 326 + handle: &Handle<'_>, 327 + ) -> jacquard_identity::resolver::Result<Did<'static>> { 328 + self.client.resolve_handle(handle).await 329 + } 330 + 331 + async fn resolve_did_doc( 332 + &self, 333 + did: &Did<'_>, 334 + ) -> jacquard_identity::resolver::Result<DidDocResponse> { 335 + self.client.resolve_did_doc(did).await 336 + } 337 + } 338 + 339 + impl<T, S> XrpcClient for OAuthClient<T, S> 340 + where 341 + S: ClientAuthStore + Send + Sync + 'static, 342 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 343 + { 344 + async fn base_uri(&self) -> Url { 345 + self.endpoint.read().await.clone().unwrap_or( 346 + Url::parse("https://public.api.bsky.app").expect("public appview should be valid url"), 347 + ) 348 + } 349 + 350 + async fn opts(&self) -> CallOptions<'_> { 351 + self.options.read().await.clone() 352 + } 353 + 354 + async fn set_opts(&self, opts: CallOptions<'_>) { 355 + let mut guard = self.options.write().await; 356 + *guard = opts.into_static(); 357 + } 358 + 359 + async fn set_base_uri(&self, url: Url) { 360 + let mut guard = self.endpoint.write().await; 361 + *guard = Some(url); 362 + } 363 + 364 + async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> 365 + where 366 + R: XrpcRequest + Send + Sync, 367 + <R as XrpcRequest>::Response: Send + Sync, 368 + { 369 + let opts = self.options.read().await.clone(); 370 + self.send_with_opts(request, opts).await 371 + } 372 + 373 + async fn send_with_opts<R>( 374 + &self, 375 + request: R, 376 + opts: CallOptions<'_>, 377 + ) -> XrpcResult<XrpcResponse<R>> 378 + where 379 + R: XrpcRequest + Send + Sync, 380 + <R as XrpcRequest>::Response: Send + Sync, 381 + { 382 + let base_uri = self.base_uri().await; 383 + self.client 384 + .xrpc(base_uri.clone()) 385 + .with_options(opts.clone()) 386 + .send(&request) 387 + .await 388 + } 389 + } 390 + 287 391 pub struct OAuthSession<T, S, W = ()> 288 392 where 289 393 T: OAuthResolver, ··· 377 481 .as_ref() 378 482 .map(|t| AuthorizationToken::Dpop(t.clone())) 379 483 } 484 + 485 + pub fn to_client(&self) -> OAuthClient<T, S> { 486 + OAuthClient::from_session(self) 487 + } 380 488 } 381 489 impl<T, S, W> OAuthSession<T, S, W> 382 490 where ··· 411 519 Self { 412 520 registry: session.registry.clone(), 413 521 client: session.client.clone(), 522 + options: RwLock::new(CallOptions::default()), 523 + endpoint: RwLock::new(None), 414 524 } 415 525 } 416 526 }
+10
crates/jacquard-oauth/src/request.rs
··· 495 495 login_hint: login_hint, 496 496 prompt: prompt.map(CowStr::from), 497 497 }; 498 + 499 + #[cfg(feature = "tracing")] 500 + tracing::debug!( 501 + parameters = ?parameters, 502 + "par:" 503 + ); 498 504 if metadata 499 505 .server_metadata 500 506 .pushed_authorization_request_endpoint ··· 937 943 jwks_uri: None, 938 944 jwks: None, 939 945 token_endpoint_auth_signing_alg: None, 946 + client_name: None, 947 + privacy_policy_uri: None, 948 + tos_uri: None, 949 + logo_uri: None, 940 950 }, 941 951 keyset: None, 942 952 }
+13
crates/jacquard-oauth/src/session.rs
··· 237 237 pub config: AtprotoClientMetadata<'s>, 238 238 } 239 239 240 + impl<'s> ClientData<'s> { 241 + pub fn new(keyset: Option<Keyset>, config: AtprotoClientMetadata<'s>) -> Self { 242 + Self { keyset, config } 243 + } 244 + 245 + pub fn new_public(config: AtprotoClientMetadata<'s>) -> Self { 246 + Self { 247 + keyset: None, 248 + config, 249 + } 250 + } 251 + } 252 + 240 253 pub struct ClientSession<'s> { 241 254 pub keyset: Option<Keyset>, 242 255 pub config: AtprotoClientMetadata<'s>,
+35
crates/jacquard-oauth/src/types.rs
··· 12 12 pub use self::response::*; 13 13 pub use self::token::*; 14 14 use jacquard_common::CowStr; 15 + use jacquard_common::IntoStatic; 15 16 use serde::Deserialize; 16 17 use url::Url; 17 18 ··· 53 54 } 54 55 } 55 56 57 + impl<'s> AuthorizeOptions<'s> { 58 + pub fn with_prompt(mut self, prompt: AuthorizeOptionPrompt) -> Self { 59 + self.prompt = Some(prompt); 60 + self 61 + } 62 + 63 + pub fn with_state(mut self, state: CowStr<'s>) -> Self { 64 + self.state = Some(state); 65 + self 66 + } 67 + 68 + pub fn with_redirect_uri(mut self, redirect_uri: Url) -> Self { 69 + self.redirect_uri = Some(redirect_uri); 70 + self 71 + } 72 + 73 + pub fn with_scopes(mut self, scopes: Vec<Scope<'s>>) -> Self { 74 + self.scopes = scopes; 75 + self 76 + } 77 + } 78 + 56 79 #[derive(Debug, Deserialize)] 57 80 pub struct CallbackParams<'s> { 58 81 #[serde(borrow)] ··· 60 83 pub state: Option<CowStr<'s>>, 61 84 pub iss: Option<CowStr<'s>>, 62 85 } 86 + 87 + impl IntoStatic for CallbackParams<'_> { 88 + type Output = CallbackParams<'static>; 89 + 90 + fn into_static(self) -> Self::Output { 91 + CallbackParams { 92 + code: self.code.into_static(), 93 + state: self.state.map(|s| s.into_static()), 94 + iss: self.iss.map(|s| s.into_static()), 95 + } 96 + } 97 + }
+13
crates/jacquard-oauth/src/types/client_metadata.rs
··· 1 1 use jacquard_common::{CowStr, IntoStatic}; 2 2 use jose_jwk::JwkSet; 3 3 use serde::{Deserialize, Serialize}; 4 + use smol_str::SmolStr; 4 5 use url::Url; 5 6 6 7 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] ··· 27 28 // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata 28 29 #[serde(skip_serializing_if = "Option::is_none")] 29 30 pub token_endpoint_auth_signing_alg: Option<CowStr<'c>>, 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + pub client_name: Option<SmolStr>, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub logo_uri: Option<Url>, 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub tos_uri: Option<Url>, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub privacy_policy_uri: Option<Url>, 30 39 } 31 40 32 41 impl OAuthClientMetadata<'_> {} ··· 50 59 token_endpoint_auth_signing_alg: self 51 60 .token_endpoint_auth_signing_alg 52 61 .map(|alg| alg.into_static()), 62 + client_name: self.client_name, 63 + logo_uri: self.logo_uri, 64 + tos_uri: self.tos_uri, 65 + privacy_policy_uri: self.privacy_policy_uri, 53 66 } 54 67 } 55 68 }
+93 -2
crates/jacquard/src/client.rs
··· 30 30 use core::future::Future; 31 31 pub use error::*; 32 32 #[cfg(feature = "api")] 33 + use jacquard_api::com_atproto::repo::get_record::GetRecordOutput; 34 + #[cfg(feature = "api")] 33 35 use jacquard_api::com_atproto::{ 34 36 repo::{ 35 37 create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, ··· 47 49 use jacquard_common::types::string::AtUri; 48 50 #[cfg(feature = "api")] 49 51 use jacquard_common::types::uri::RecordUri; 50 - #[cfg(not(target_arch = "wasm32"))] 51 52 use jacquard_common::xrpc::XrpcResponse; 52 53 use jacquard_common::xrpc::{ 53 54 CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest, XrpcResp, ··· 62 63 }; 63 64 use jacquard_identity::{JacquardResolver, slingshot_resolver_default}; 64 65 use jacquard_oauth::authstore::ClientAuthStore; 65 - use jacquard_oauth::client::OAuthSession; 66 + use jacquard_oauth::client::{OAuthClient, OAuthSession}; 66 67 use jacquard_oauth::dpop::DpopExt; 67 68 use jacquard_oauth::resolver::OAuthResolver; 68 69 use serde::Serialize; ··· 506 507 Self { inner } 507 508 } 508 509 510 + pub fn inner(&self) -> &A { 511 + &self.inner 512 + } 513 + 509 514 /// Return the underlying session kind. 510 515 pub fn kind(&self) -> AgentKind { 511 516 self.inner.session_kind() ··· 760 765 } 761 766 } 762 767 768 + /// Untyped, freeform record fetcher. 769 + /// Hits [https://slingshot.microcosm.blue] 770 + fn fetch_record_slingshot( 771 + &self, 772 + uri: &AtUri<'_>, 773 + ) -> impl Future<Output = Result<GetRecordOutput<'static>>> { 774 + async move { 775 + #[cfg(feature = "tracing")] 776 + let _span = tracing::debug_span!("fetch_record_slingshot", uri = %uri).entered(); 777 + 778 + // Make stateless XRPC call to that PDS (no auth required for public records) 779 + use jacquard_api::com_atproto::repo::get_record::GetRecord; 780 + let collection = uri.collection().clone().ok_or(AgentError::sub_operation( 781 + "no collection", 782 + ClientError::invalid_request("no collection"), 783 + ))?; 784 + let rkey = uri.rkey().ok_or(AgentError::sub_operation( 785 + "no rkey", 786 + ClientError::invalid_request("no rkey"), 787 + ))?; 788 + let request = GetRecord::new() 789 + .repo(uri.authority().clone()) 790 + .collection(collection.clone()) 791 + .rkey(rkey.clone()) 792 + .build(); 793 + 794 + let response: Response<GetRecordResponse> = { 795 + use url::Url; 796 + 797 + let http_request = xrpc::build_http_request( 798 + &Url::parse("https://slingshot.microcosm.blue") 799 + .expect("slingshot url is valid"), 800 + &request, 801 + &self.opts().await, 802 + )?; 803 + 804 + let http_response = self 805 + .send_http(http_request) 806 + .await 807 + .map_err(|e| ClientError::transport(e))?; 808 + 809 + xrpc::process_response(http_response) 810 + }?; 811 + let output = response.into_output().map_err(|e| match e { 812 + XrpcError::Auth(auth) => AgentError::from(auth), 813 + e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e), 814 + XrpcError::Xrpc(typed) => AgentError::new( 815 + AgentErrorKind::SubOperation { 816 + step: "fetch record", 817 + }, 818 + None, 819 + ) 820 + .with_details(typed.to_string()), 821 + })?; 822 + Ok(output) 823 + } 824 + } 825 + 763 826 /// Fetches a record from the PDS. Returns an owned, parsed response. 764 827 /// 765 828 /// Takes an at:// URI annotated with the collection type, which be constructed with `R::uri(uri)` ··· 1165 1228 .await 1166 1229 .map(|t| t.into_static()) 1167 1230 .map_err(|e| ClientError::transport(e).with_context("OAuth token refresh failed")) 1231 + } 1232 + } 1233 + } 1234 + 1235 + impl<T, S> AgentSession for OAuthClient<T, S> 1236 + where 1237 + S: ClientAuthStore + Send + Sync + 'static, 1238 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 1239 + { 1240 + fn session_kind(&self) -> AgentKind { 1241 + AgentKind::OAuth 1242 + } 1243 + fn session_info( 1244 + &self, 1245 + ) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> { 1246 + async { None } 1247 + } 1248 + fn endpoint(&self) -> impl Future<Output = url::Url> { 1249 + async { self.base_uri().await } 1250 + } 1251 + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> { 1252 + async { self.set_opts(opts).await } 1253 + } 1254 + fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> { 1255 + async { 1256 + Err(ClientError::auth( 1257 + jacquard_common::error::AuthError::NotAuthenticated, 1258 + )) 1168 1259 } 1169 1260 } 1170 1261 }