A better Rust ATProto crate

bunch of progress, different approach

Orual 36ef115b a0fe35e3

+34 -4
Cargo.lock
··· 244 244 245 245 [[package]] 246 246 name = "bon" 247 - version = "3.7.2" 247 + version = "3.8.0" 248 248 source = "registry+https://github.com/rust-lang/crates.io-index" 249 - checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb" 249 + checksum = "f44aa969f86ffb99e5c2d51f393ec9ed6e9fe2f47b609c917b0071f129854d29" 250 250 dependencies = [ 251 251 "bon-macros", 252 252 "rustversion", ··· 254 254 255 255 [[package]] 256 256 name = "bon-macros" 257 - version = "3.7.2" 257 + version = "3.8.0" 258 258 source = "registry+https://github.com/rust-lang/crates.io-index" 259 - checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005" 259 + checksum = "e1e78cd86b6a6515d87392332fd63c4950ed3e50eab54275259a5f59f3666f90" 260 260 dependencies = [ 261 261 "darling", 262 262 "ident_case", ··· 566 566 ] 567 567 568 568 [[package]] 569 + name = "crossbeam-utils" 570 + version = "0.8.21" 571 + source = "registry+https://github.com/rust-lang/crates.io-index" 572 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 573 + 574 + [[package]] 569 575 name = "crunchy" 570 576 version = "0.2.4" 571 577 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 655 661 ] 656 662 657 663 [[package]] 664 + name = "dashmap" 665 + version = "6.1.0" 666 + source = "registry+https://github.com/rust-lang/crates.io-index" 667 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 668 + dependencies = [ 669 + "cfg-if", 670 + "crossbeam-utils", 671 + "hashbrown 0.14.5", 672 + "lock_api", 673 + "once_cell", 674 + "parking_lot_core", 675 + ] 676 + 677 + [[package]] 658 678 name = "data-encoding" 659 679 version = "2.9.0" 660 680 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1060 1080 version = "0.12.3" 1061 1081 source = "registry+https://github.com/rust-lang/crates.io-index" 1062 1082 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 1083 + 1084 + [[package]] 1085 + name = "hashbrown" 1086 + version = "0.14.5" 1087 + source = "registry+https://github.com/rust-lang/crates.io-index" 1088 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1063 1089 1064 1090 [[package]] 1065 1091 name = "hashbrown" ··· 1631 1657 dependencies = [ 1632 1658 "async-trait", 1633 1659 "base64 0.22.1", 1660 + "bon", 1634 1661 "chrono", 1662 + "dashmap", 1635 1663 "elliptic-curve", 1636 1664 "http", 1637 1665 "jacquard-common", ··· 1641 1669 "p256", 1642 1670 "rand 0.8.5", 1643 1671 "rand_core 0.6.4", 1672 + "reqwest", 1644 1673 "serde", 1645 1674 "serde_html_form", 1646 1675 "serde_json", ··· 1648 1677 "signature", 1649 1678 "smol_str", 1650 1679 "thiserror 2.0.17", 1680 + "tokio", 1651 1681 "url", 1652 1682 "uuid", 1653 1683 ]
+84 -3
crates/jacquard-common/src/cowstr.rs
··· 1 - use serde::{Deserialize, Serialize}; 2 - use smol_str::SmolStr; 1 + use serde::{Deserialize, Serialize, de::DeserializeOwned}; 2 + use smol_str::{SmolStr, ToSmolStr}; 3 3 use std::{ 4 4 borrow::Cow, 5 5 fmt, ··· 62 62 #[inline] 63 63 pub unsafe fn from_utf8_unchecked(s: &'s [u8]) -> Self { 64 64 unsafe { Self::Owned(SmolStr::new(std::str::from_utf8_unchecked(s))) } 65 + } 66 + 67 + /// Returns a reference to the underlying string slice. 68 + #[inline] 69 + pub fn as_str(&self) -> &str { 70 + match self { 71 + CowStr::Borrowed(s) => s, 72 + CowStr::Owned(s) => s.as_str(), 73 + } 65 74 } 66 75 } 67 76 ··· 145 154 } 146 155 } 147 156 157 + impl From<CowStr<'_>> for SmolStr { 158 + #[inline] 159 + fn from(s: CowStr<'_>) -> Self { 160 + match s { 161 + CowStr::Borrowed(s) => SmolStr::new(s), 162 + CowStr::Owned(s) => SmolStr::new(s), 163 + } 164 + } 165 + } 166 + 167 + impl From<SmolStr> for CowStr<'_> { 168 + #[inline] 169 + fn from(s: SmolStr) -> Self { 170 + CowStr::Owned(s) 171 + } 172 + } 173 + 148 174 impl From<CowStr<'_>> for Box<str> { 149 175 #[inline] 150 176 fn from(s: CowStr<'_>) -> Self { ··· 257 283 } 258 284 } 259 285 260 - impl<'de: 'a, 'a> Deserialize<'de> for CowStr<'a> { 286 + // impl<'de> Deserialize<'de> for CowStr<'_> { 287 + // #[inline] 288 + // fn deserialize<D>(deserializer: D) -> Result<CowStr<'static>, D::Error> 289 + // where 290 + // D: serde::Deserializer<'de>, 291 + // { 292 + // struct CowStrVisitor; 293 + 294 + // impl<'de> serde::de::Visitor<'de> for CowStrVisitor { 295 + // type Value = CowStr<'static>; 296 + 297 + // #[inline] 298 + // fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 299 + // write!(formatter, "a string") 300 + // } 301 + 302 + // #[inline] 303 + // fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 304 + // where 305 + // E: serde::de::Error, 306 + // { 307 + // Ok(CowStr::copy_from_str(v)) 308 + // } 309 + 310 + // #[inline] 311 + // fn visit_string<E>(self, v: String) -> Result<Self::Value, E> 312 + // where 313 + // E: serde::de::Error, 314 + // { 315 + // Ok(v.into()) 316 + // } 317 + // } 318 + 319 + // deserializer.deserialize_str(CowStrVisitor) 320 + // } 321 + // } 322 + 323 + impl<'de, 'a, 'b> Deserialize<'de> for CowStr<'a> 324 + where 325 + 'de: 'a, 326 + { 261 327 #[inline] 262 328 fn deserialize<D>(deserializer: D) -> Result<CowStr<'a>, D::Error> 263 329 where ··· 299 365 } 300 366 301 367 deserializer.deserialize_str(CowStrVisitor) 368 + } 369 + } 370 + 371 + /// Convert to a CowStr. 372 + pub trait ToCowStr { 373 + /// Convert to a CowStr. 374 + fn to_cowstr(&self) -> CowStr<'_>; 375 + } 376 + 377 + impl<T> ToCowStr for T 378 + where 379 + T: fmt::Display + ?Sized, 380 + { 381 + fn to_cowstr(&self) -> CowStr<'_> { 382 + CowStr::Owned(smol_str::format_smolstr!("{}", self)) 302 383 } 303 384 } 304 385
+18 -1
crates/jacquard-common/src/ident_resolver.rs
··· 291 291 /// - PLC directory or Slingshot for `did:plc` 292 292 /// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source 293 293 /// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest 294 - #[async_trait::async_trait] 294 + #[async_trait::async_trait()] 295 295 pub trait IdentityResolver { 296 296 /// Access options for validation decisions in default methods 297 297 fn options(&self) -> &ResolverOptions; ··· 360 360 let did = self.resolve_handle(handle).await?; 361 361 let pds = self.pds_for_did(&did).await?; 362 362 Ok((did, pds)) 363 + } 364 + } 365 + 366 + #[async_trait::async_trait] 367 + impl<T: IdentityResolver + Sync + Send> IdentityResolver for std::sync::Arc<T> { 368 + fn options(&self) -> &ResolverOptions { 369 + self.as_ref().options() 370 + } 371 + 372 + /// Resolve handle 373 + async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 374 + self.as_ref().resolve_handle(handle).await 375 + } 376 + 377 + /// Resolve DID document 378 + async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 379 + self.as_ref().resolve_did_doc(did).await 363 380 } 364 381 } 365 382
-6
crates/jacquard-common/src/types/datetime.rs
··· 203 203 } 204 204 } 205 205 206 - impl AsRef<str> for Datetime { 207 - fn as_ref(&self) -> &str { 208 - self.as_str() 209 - } 210 - } 211 - 212 206 impl fmt::Display for Datetime { 213 207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 214 208 f.write_str(self.as_str())
+4
crates/jacquard-oauth/Cargo.toml
··· 27 27 http.workspace = true 28 28 rand = { version = "0.8.5", features = ["small_rng"] } 29 29 async-trait = "0.1.89" 30 + dashmap = "6.1.0" 31 + tokio = { version = "1.47.1", features = ["sync"] } 32 + bon = "3.8.0" 33 + reqwest.workspace = true
+103 -100
crates/jacquard-oauth/src/atproto.rs
··· 76 76 } 77 77 } 78 78 79 - pub fn localhost_client_metadata<'s>( 80 - redirect_uris: Option<Vec<Url>>, 81 - scopes: Option<&'s [Scope<'s>]>, 82 - ) -> Result<OAuthClientMetadata<'s>> { 83 - // validate redirect_uris 84 - if let Some(redirect_uris) = &redirect_uris { 85 - for redirect_uri in redirect_uris { 86 - if redirect_uri.scheme() != "http" { 87 - return Err(Error::LocalhostClient(LocalhostClientError::NotHttpScheme)); 88 - } 89 - if redirect_uri.host().map(|h| h.to_owned()) == Some(Host::parse("localhost").unwrap()) 90 - { 91 - return Err(Error::LocalhostClient(LocalhostClientError::Localhost)); 92 - } 93 - if redirect_uri 94 - .host() 95 - .map(|h| h.to_owned()) 96 - .map_or(true, |host| { 97 - host != Host::parse("127.0.0.1").unwrap() 98 - && host != Host::parse("[::1]").unwrap() 99 - }) 100 - { 101 - return Err(Error::LocalhostClient( 102 - LocalhostClientError::NotLoopbackHost, 103 - )); 104 - } 105 - } 106 - } 107 - // determine client_id 108 - #[derive(serde::Serialize)] 109 - struct Parameters<'a> { 110 - #[serde(skip_serializing_if = "Option::is_none")] 111 - redirect_uri: Option<Vec<Url>>, 112 - #[serde(skip_serializing_if = "Option::is_none")] 113 - scope: Option<CowStr<'a>>, 114 - } 115 - let query = serde_html_form::to_string(Parameters { 116 - redirect_uri: redirect_uris.clone(), 117 - scope: scopes.map(|s| Scope::serialize_multiple(s)), 118 - })?; 119 - let mut client_id = String::from("http://localhost"); 120 - if !query.is_empty() { 121 - client_id.push_str(&format!("?{query}")); 122 - } 123 - Ok(OAuthClientMetadata { 124 - client_id: Url::parse(&client_id).unwrap(), 125 - client_uri: None, 126 - redirect_uris: redirect_uris.unwrap_or(vec![ 127 - Url::from_str("http://127.0.0.1/").unwrap(), 128 - Url::from_str("http://[::1]/").unwrap(), 129 - ]), 130 - scope: None, 131 - grant_types: None, // will be set to `authorization_code` and `refresh_token` 132 - token_endpoint_auth_method: Some(CowStr::new_static("none")), 133 - dpop_bound_access_tokens: None, // will be set to `true` 134 - jwks_uri: None, 135 - jwks: None, 136 - token_endpoint_auth_signing_alg: None, 137 - }) 138 - } 139 - 140 79 #[derive(Clone, Debug, PartialEq, Eq)] 141 80 pub struct AtprotoClientMetadata<'m> { 142 81 pub client_id: Url, 143 82 pub client_uri: Option<Url>, 144 83 pub redirect_uris: Vec<Url>, 145 - pub token_endpoint_auth_method: AuthMethod, 146 84 pub grant_types: Vec<GrantType>, 147 85 pub scopes: Vec<Scope<'m>>, 148 86 pub jwks_uri: Option<Url>, 149 - pub token_endpoint_auth_signing_alg: Option<CowStr<'m>>, 87 + } 88 + 89 + impl<'m> AtprotoClientMetadata<'m> { 90 + pub fn new( 91 + client_id: Url, 92 + client_uri: Option<Url>, 93 + redirect_uris: Vec<Url>, 94 + grant_types: Vec<GrantType>, 95 + scopes: Vec<Scope<'m>>, 96 + jwks_uri: Option<Url>, 97 + ) -> Self { 98 + Self { 99 + client_id, 100 + client_uri, 101 + redirect_uris, 102 + grant_types, 103 + scopes, 104 + jwks_uri, 105 + } 106 + } 107 + 108 + pub fn new_localhost( 109 + mut redirect_uris: Option<Vec<Url>>, 110 + scopes: Option<Vec<Scope<'m>>>, 111 + ) -> Self { 112 + // coerce redirect uris to localhost 113 + if let Some(redirect_uris) = &mut redirect_uris { 114 + for redirect_uri in redirect_uris { 115 + redirect_uri.set_host(Some("http://localhost")).unwrap(); 116 + } 117 + } 118 + // determine client_id 119 + #[derive(serde::Serialize)] 120 + struct Parameters<'a> { 121 + #[serde(skip_serializing_if = "Option::is_none")] 122 + redirect_uri: Option<Vec<Url>>, 123 + #[serde(skip_serializing_if = "Option::is_none")] 124 + scope: Option<CowStr<'a>>, 125 + } 126 + let query = serde_html_form::to_string(Parameters { 127 + redirect_uri: redirect_uris.clone(), 128 + scope: scopes 129 + .as_ref() 130 + .map(|s| Scope::serialize_multiple(s.as_slice())), 131 + }) 132 + .ok(); 133 + let mut client_id = String::from("http://localhost"); 134 + if let Some(query) = query 135 + && !query.is_empty() 136 + { 137 + client_id.push_str(&format!("?{query}")); 138 + } 139 + Self { 140 + client_id: Url::parse(&client_id).unwrap(), 141 + client_uri: None, 142 + redirect_uris: redirect_uris.unwrap_or(vec![ 143 + Url::from_str("http://127.0.0.1/").unwrap(), 144 + Url::from_str("http://[::1]/").unwrap(), 145 + ]), 146 + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 147 + scopes: scopes.unwrap_or(vec![Scope::Atproto]), 148 + jwks_uri: None, 149 + } 150 + } 150 151 } 151 152 152 153 pub fn atproto_client_metadata<'m>( ··· 162 163 if !metadata.scopes.contains(&Scope::Atproto) { 163 164 return Err(Error::InvalidScope); 164 165 } 165 - let (jwks_uri, mut jwks) = (metadata.jwks_uri, None); 166 - match metadata.token_endpoint_auth_method { 167 - AuthMethod::None => { 168 - if metadata.token_endpoint_auth_signing_alg.is_some() { 169 - return Err(Error::AuthSigningAlg); 170 - } 171 - } 172 - AuthMethod::PrivateKeyJwt => { 173 - if let Some(keyset) = keyset { 174 - if metadata.token_endpoint_auth_signing_alg.is_none() { 175 - return Err(Error::AuthSigningAlg); 176 - } 177 - if jwks_uri.is_none() { 178 - jwks = Some(keyset.public_jwks()); 179 - } 180 - } else { 181 - return Err(Error::EmptyJwks); 182 - } 183 - } 184 - } 166 + let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset { 167 + let jwks = if metadata.jwks_uri.is_none() { 168 + Some(keyset.public_jwks()) 169 + } else { 170 + None 171 + }; 172 + (AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks) 173 + } else { 174 + (AuthMethod::None, None, None) 175 + }; 176 + 185 177 Ok(OAuthClientMetadata { 186 178 client_id: metadata.client_id, 187 179 client_uri: metadata.client_uri, 188 180 redirect_uris: metadata.redirect_uris, 189 - token_endpoint_auth_method: Some(metadata.token_endpoint_auth_method.into()), 181 + token_endpoint_auth_method: Some(auth_method.into()), 190 182 grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()), 191 183 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 192 184 dpop_bound_access_tokens: Some(true), 193 185 jwks_uri, 194 186 jwks, 195 - token_endpoint_auth_signing_alg: metadata.token_endpoint_auth_signing_alg, 187 + token_endpoint_auth_signing_alg: if keyset.is_some() { 188 + Some(CowStr::new_static("ES256")) 189 + } else { 190 + None 191 + }, 196 192 }) 197 193 } 198 194 ··· 216 212 #[test] 217 213 fn test_localhost_client_metadata_default() { 218 214 assert_eq!( 219 - localhost_client_metadata(None, None).expect("failed to convert metadata"), 215 + atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None) 216 + .unwrap(), 220 217 OAuthClientMetadata { 221 218 client_id: Url::from_str("http://localhost").unwrap(), 222 219 client_uri: None, ··· 238 235 #[test] 239 236 fn test_localhost_client_metadata_custom() { 240 237 assert_eq!( 241 - localhost_client_metadata( 238 + atproto_client_metadata(AtprotoClientMetadata::new_localhost( 242 239 Some(vec![ 243 240 Url::from_str("http://127.0.0.1/callback").unwrap(), 244 241 Url::from_str("http://[::1]/callback").unwrap(), ··· 249 246 Scope::Transition(TransitionScope::Generic), 250 247 Scope::parse("account:email").unwrap() 251 248 ] 252 - .as_slice() 253 249 ) 254 - ) 250 + ), &None) 255 251 .expect("failed to convert metadata"), 256 252 OAuthClientMetadata { 257 253 client_id: Url::from_str( ··· 276 272 #[test] 277 273 fn test_localhost_client_metadata_invalid() { 278 274 { 279 - let err = localhost_client_metadata( 280 - Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]), 281 - None, 275 + let err = atproto_client_metadata( 276 + AtprotoClientMetadata::new_localhost( 277 + Some(vec![Url::from_str("https://127.0.0.1/").unwrap()]), 278 + None, 279 + ), 280 + &None, 282 281 ) 283 282 .expect_err("expected to fail"); 284 283 assert!(matches!( ··· 287 286 )); 288 287 } 289 288 { 290 - let err = localhost_client_metadata( 291 - Some(vec![Url::from_str("http://localhost:8000/").unwrap()]), 292 - None, 289 + let err = atproto_client_metadata( 290 + AtprotoClientMetadata::new_localhost( 291 + Some(vec![Url::from_str("http://localhost:8000/").unwrap()]), 292 + None, 293 + ), 294 + &None, 293 295 ) 294 296 .expect_err("expected to fail"); 295 297 assert!(matches!( ··· 298 300 )); 299 301 } 300 302 { 301 - let err = localhost_client_metadata( 302 - Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]), 303 - None, 303 + let err = atproto_client_metadata( 304 + AtprotoClientMetadata::new_localhost( 305 + Some(vec![Url::from_str("http://192.168.0.0/").unwrap()]), 306 + None, 307 + ), 308 + &None, 304 309 ) 305 310 .expect_err("expected to fail"); 306 311 assert!(matches!( ··· 316 321 client_id: Url::from_str("https://example.com/client_metadata.json").unwrap(), 317 322 client_uri: Some(Url::from_str("https://example.com").unwrap()), 318 323 redirect_uris: vec![Url::from_str("https://example.com/callback").unwrap()], 319 - token_endpoint_auth_method: AuthMethod::PrivateKeyJwt, 320 324 grant_types: vec![GrantType::AuthorizationCode], 321 325 scopes: vec![Scope::Atproto], 322 326 jwks_uri: None, 323 - token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), 324 327 }; 325 328 { 326 329 let metadata = metadata.clone();
+33
crates/jacquard-oauth/src/authstore.rs
··· 1 + use jacquard_common::{session::SessionStoreError, types::did::Did}; 2 + 3 + use crate::session::{AuthRequestData, ClientSessionData}; 4 + 5 + #[async_trait::async_trait] 6 + pub trait ClientAuthStore { 7 + async fn get_session( 8 + &self, 9 + did: &Did<'_>, 10 + session_id: &str, 11 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError>; 12 + 13 + async fn upsert_session(&self, session: ClientSessionData<'_>) 14 + -> Result<(), SessionStoreError>; 15 + 16 + async fn delete_session( 17 + &self, 18 + did: &Did<'_>, 19 + session_id: &str, 20 + ) -> Result<(), SessionStoreError>; 21 + 22 + async fn get_auth_req_info( 23 + &self, 24 + state: &str, 25 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError>; 26 + 27 + async fn save_auth_req_info( 28 + &self, 29 + auth_req_info: &AuthRequestData<'_>, 30 + ) -> Result<(), SessionStoreError>; 31 + 32 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError>; 33 + }
+186
crates/jacquard-oauth/src/client.rs
··· 1 + use std::sync::Arc; 2 + 3 + use jacquard_common::{CowStr, IntoStatic, types::did::Did}; 4 + use jose_jwk::JwkSet; 5 + use url::Url; 6 + 7 + use crate::{ 8 + atproto::atproto_client_metadata, 9 + authstore::ClientAuthStore, 10 + dpop::DpopExt, 11 + error::{OAuthError, Result}, 12 + request::{OAuthMetadata, exchange_code, par}, 13 + resolver::OAuthResolver, 14 + scopes::Scope, 15 + session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry}, 16 + types::{AuthorizeOptions, CallbackParams}, 17 + }; 18 + 19 + pub struct OAuthClient<T, S> 20 + where 21 + T: OAuthResolver, 22 + S: ClientAuthStore, 23 + { 24 + pub registry: Arc<SessionRegistry<T, S>>, 25 + pub client: Arc<T>, 26 + } 27 + 28 + impl<T, S> OAuthClient<T, S> 29 + where 30 + T: OAuthResolver, 31 + S: ClientAuthStore, 32 + { 33 + pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self { 34 + let client = Arc::new(client); 35 + let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); 36 + Self { registry, client } 37 + } 38 + } 39 + 40 + impl<T, S> OAuthClient<T, S> 41 + where 42 + S: ClientAuthStore + Send + Sync + 'static, 43 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 44 + { 45 + pub fn jwks(&self) -> JwkSet { 46 + self.registry 47 + .client_data 48 + .keyset 49 + .as_ref() 50 + .map(|keyset| keyset.public_jwks()) 51 + .unwrap_or_default() 52 + } 53 + pub async fn start_auth( 54 + &self, 55 + input: impl AsRef<str>, 56 + options: AuthorizeOptions<'_>, 57 + ) -> Result<String> { 58 + let client_metadata = atproto_client_metadata( 59 + self.registry.client_data.config.clone(), 60 + &self.registry.client_data.keyset, 61 + )?; 62 + 63 + let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?; 64 + let login_hint = if identity.is_some() { 65 + Some(input.as_ref().into()) 66 + } else { 67 + None 68 + }; 69 + let metadata = OAuthMetadata { 70 + server_metadata, 71 + client_metadata, 72 + keyset: self.registry.client_data.keyset.clone(), 73 + }; 74 + let auth_req_info = 75 + par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?; 76 + 77 + #[derive(serde::Serialize)] 78 + struct Parameters<'s> { 79 + client_id: Url, 80 + request_uri: CowStr<'s>, 81 + } 82 + Ok(metadata.server_metadata.authorization_endpoint.to_string() 83 + + "?" 84 + + &serde_html_form::to_string(Parameters { 85 + client_id: metadata.client_metadata.client_id.clone(), 86 + request_uri: auth_req_info.request_uri, 87 + }) 88 + .unwrap()) 89 + } 90 + 91 + pub async fn callback(&self, params: CallbackParams<'_>) -> Result<ClientSessionData<'static>> { 92 + let Some(state_key) = params.state else { 93 + return Err(OAuthError::Callback("missing state parameter".into())); 94 + }; 95 + 96 + let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else { 97 + return Err(OAuthError::Callback(format!( 98 + "unknown authorization state: {state_key}" 99 + ))); 100 + }; 101 + 102 + self.registry.store.delete_auth_req_info(&state_key).await?; 103 + 104 + let metadata = self 105 + .client 106 + .get_authorization_server_metadata(&auth_req_info.authserver_url) 107 + .await?; 108 + 109 + if let Some(iss) = params.iss { 110 + if iss != metadata.issuer { 111 + return Err(OAuthError::Callback(format!( 112 + "issuer mismatch: expected {}, got {iss}", 113 + metadata.issuer 114 + ))); 115 + } 116 + } else if metadata.authorization_response_iss_parameter_supported == Some(true) { 117 + return Err(OAuthError::Callback("missing `iss` parameter".into())); 118 + } 119 + let metadata = OAuthMetadata { 120 + server_metadata: metadata, 121 + client_metadata: atproto_client_metadata( 122 + self.registry.client_data.config.clone(), 123 + &self.registry.client_data.keyset, 124 + )?, 125 + keyset: self.registry.client_data.keyset.clone(), 126 + }; 127 + let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone(); 128 + 129 + match exchange_code( 130 + self.client.as_ref(), 131 + &mut auth_req_info.dpop_data.clone(), 132 + &params.code, 133 + &auth_req_info.pkce_verifier, 134 + &metadata, 135 + ) 136 + .await 137 + { 138 + Ok(token_set) => { 139 + let scopes = if let Some(scope) = &token_set.scope { 140 + Scope::parse_multiple_reduced(&scope) 141 + .expect("Failed to parse scopes") 142 + .into_static() 143 + } else { 144 + vec![] 145 + }; 146 + let client_data = ClientSessionData { 147 + account_did: token_set.sub.clone(), 148 + session_id: auth_req_info.state, 149 + host_url: Url::parse(&token_set.iss).expect("Failed to parse host URL"), 150 + authserver_url: auth_req_info.authserver_url, 151 + authserver_token_endpoint: auth_req_info.authserver_token_endpoint, 152 + authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint, 153 + scopes, 154 + dpop_data: DpopClientData { 155 + dpop_key: auth_req_info.dpop_data.dpop_key.clone(), 156 + dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()), 157 + dpop_host_nonce: auth_req_info 158 + .dpop_data 159 + .dpop_authserver_nonce 160 + .unwrap_or(CowStr::default()), 161 + }, 162 + token_set, 163 + }; 164 + 165 + Ok(client_data.into_static()) 166 + } 167 + Err(e) => Err(e.into()), 168 + } 169 + } 170 + 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()) 181 + } 182 + 183 + pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> { 184 + Ok(self.registry.del(did, session_id).await?) 185 + } 186 + }
+179 -179
crates/jacquard-oauth/src/dpop.rs
··· 1 1 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 2 2 use chrono::Utc; 3 3 use http::{Request, Response, header::InvalidHeaderValue}; 4 - use jacquard_common::{ 5 - CowStr, 6 - http_client::HttpClient, 7 - session::{MemorySessionStore, SessionStore, SessionStoreError}, 8 - }; 4 + use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient}; 9 5 use jose_jwa::{Algorithm, Signing}; 10 6 use jose_jwk::{Jwk, Key, crypto}; 11 7 use p256::ecdsa::SigningKey; 12 8 use rand::{RngCore, SeedableRng}; 13 9 use sha2::Digest; 14 - use smol_str::{SmolStr, ToSmolStr}; 15 10 16 - use crate::jose::{ 17 - create_signed_jwt, 18 - jws::RegisteredHeader, 19 - jwt::{Claims, PublicClaims, RegisteredClaims}, 11 + use crate::{ 12 + jose::{ 13 + create_signed_jwt, 14 + jws::RegisteredHeader, 15 + jwt::{Claims, PublicClaims, RegisteredClaims}, 16 + }, 17 + session::DpopDataSource, 20 18 }; 21 19 22 20 pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; ··· 30 28 pub enum Error { 31 29 #[error(transparent)] 32 30 InvalidHeaderValue(#[from] InvalidHeaderValue), 33 - #[error(transparent)] 34 - SessionStore(#[from] SessionStoreError), 35 31 #[error("crypto error: {0:?}")] 36 32 JwkCrypto(crypto::Error), 37 33 #[error("key does not match any alg supported by the server")] ··· 44 40 45 41 type Result<T> = core::result::Result<T, Error>; 46 42 43 + #[async_trait::async_trait] 44 + pub trait DpopClient: HttpClient { 45 + async fn dpop_server(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 46 + async fn dpop_client(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 47 + async fn wrap_request(&self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>>; 48 + } 49 + 50 + pub trait DpopExt: HttpClient { 51 + fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D> 52 + where 53 + Self: Sized, 54 + D: DpopDataSource, 55 + { 56 + DpopCall::server(self, data_source) 57 + } 58 + 59 + fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N> 60 + where 61 + Self: Sized, 62 + N: DpopDataSource, 63 + { 64 + DpopCall::client(self, data_source) 65 + } 66 + 67 + async fn wrap_with_dpop<'r, D>( 68 + &'r self, 69 + is_to_auth_server: bool, 70 + data_source: &'r mut D, 71 + request: Request<Vec<u8>>, 72 + ) -> Result<Response<Vec<u8>>> 73 + where 74 + Self: Sized, 75 + D: DpopDataSource, 76 + { 77 + wrap_request_with_dpop(self, data_source, is_to_auth_server, request).await 78 + } 79 + } 80 + 81 + pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> { 82 + pub client: &'r C, 83 + pub is_to_auth_server: bool, 84 + pub data_source: &'r mut D, 85 + } 86 + 87 + impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> { 88 + pub fn server(client: &'r C, data_source: &'r mut N) -> Self { 89 + Self { 90 + client, 91 + is_to_auth_server: true, 92 + data_source, 93 + } 94 + } 95 + 96 + pub fn client(client: &'r C, data_source: &'r mut N) -> Self { 97 + Self { 98 + client, 99 + is_to_auth_server: false, 100 + data_source, 101 + } 102 + } 103 + 104 + pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> { 105 + wrap_request_with_dpop( 106 + self.client, 107 + self.data_source, 108 + self.is_to_auth_server, 109 + request, 110 + ) 111 + .await 112 + } 113 + } 114 + 115 + pub async fn wrap_request_with_dpop<T, N>( 116 + client: &T, 117 + data_source: &mut N, 118 + is_to_auth_server: bool, 119 + mut request: Request<Vec<u8>>, 120 + ) -> Result<Response<Vec<u8>>> 121 + where 122 + T: HttpClient, 123 + N: DpopDataSource, 124 + { 125 + let uri = request.uri().clone(); 126 + let method = request.method().to_cowstr().into_static(); 127 + let uri = uri.to_cowstr(); 128 + // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 129 + let ath = request 130 + .headers() 131 + .get("Authorization") 132 + .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 133 + .map(|auth| { 134 + URL_SAFE_NO_PAD 135 + .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 136 + .into() 137 + }); 138 + 139 + let init_nonce = if is_to_auth_server { 140 + data_source.authserver_nonce() 141 + } else { 142 + data_source.host_nonce() 143 + }; 144 + let init_proof = build_dpop_proof( 145 + data_source.key(), 146 + method.clone(), 147 + uri.clone(), 148 + init_nonce.clone(), 149 + ath.clone(), 150 + )?; 151 + request.headers_mut().insert("DPoP", init_proof.parse()?); 152 + let response = client 153 + .send_http(request.clone()) 154 + .await 155 + .map_err(|e| Error::Inner(e.into()))?; 156 + 157 + let next_nonce = response 158 + .headers() 159 + .get("DPoP-Nonce") 160 + .and_then(|v| v.to_str().ok()) 161 + .map(|c| c.to_cowstr()); 162 + match &next_nonce { 163 + Some(s) if next_nonce != init_nonce => { 164 + // Store the fresh nonce for future requests 165 + if is_to_auth_server { 166 + data_source.set_authserver_nonce(s.clone()); 167 + } else { 168 + data_source.set_host_nonce(s.clone()); 169 + } 170 + } 171 + _ => { 172 + // No nonce was returned or it is the same as the one we sent. No need to 173 + // update the nonce store, or retry the request. 174 + return Ok(response); 175 + } 176 + } 177 + 178 + if !is_use_dpop_nonce_error(is_to_auth_server, &response) { 179 + return Ok(response); 180 + } 181 + let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?; 182 + request.headers_mut().insert("DPoP", next_proof.parse()?); 183 + let response = client 184 + .send_http(request) 185 + .await 186 + .map_err(|e| Error::Inner(e.into()))?; 187 + Ok(response) 188 + } 189 + 190 + #[inline] 191 + fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool { 192 + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 193 + if is_to_auth_server { 194 + if response.status() == 400 { 195 + if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 196 + return res.error == "use_dpop_nonce"; 197 + }; 198 + } 199 + } 200 + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 201 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 202 + else if response.status() == 401 { 203 + if let Some(www_auth) = response 204 + .headers() 205 + .get("WWW-Authenticate") 206 + .and_then(|v| v.to_str().ok()) 207 + { 208 + return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#); 209 + } 210 + } 211 + false 212 + } 213 + 47 214 #[inline] 48 215 pub(crate) fn generate_jti() -> CowStr<'static> { 49 216 let mut rng = rand::rngs::SmallRng::from_entropy(); ··· 65 232 crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 66 233 _ => return Err(Error::UnsupportedKey), 67 234 }; 68 - build_dpop_proof_with_secret(&secret, method, url, nonce, ath) 69 - } 70 - 71 - /// Same as build_dpop_proof but takes a parsed secret key to avoid JSON roundtrips. 72 - #[inline] 73 - pub fn build_dpop_proof_with_secret<'s>( 74 - secret: &p256::SecretKey, 75 - method: CowStr<'s>, 76 - url: CowStr<'s>, 77 - nonce: Option<CowStr<'s>>, 78 - ath: Option<CowStr<'s>>, 79 - ) -> Result<CowStr<'s>> { 80 235 let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 81 236 header.typ = Some(JWT_HEADER_TYP_DPOP.into()); 82 237 header.jwk = Some(Jwk { ··· 103 258 claims, 104 259 )?) 105 260 } 106 - 107 - pub struct DpopClient<T, S = MemorySessionStore<CowStr<'static>, CowStr<'static>>> 108 - where 109 - S: SessionStore<CowStr<'static>, CowStr<'static>>, 110 - { 111 - inner: T, 112 - pub(crate) key: Key, 113 - nonces: S, 114 - is_auth_server: bool, 115 - } 116 - 117 - impl<T> DpopClient<T> { 118 - pub fn new( 119 - key: Key, 120 - http_client: T, 121 - is_auth_server: bool, 122 - supported_algs: &Option<Vec<CowStr<'static>>>, 123 - ) -> Result<Self> { 124 - if let Some(algs) = supported_algs { 125 - let alg = CowStr::from(match &key { 126 - Key::Ec(ec) => match &ec.crv { 127 - jose_jwk::EcCurves::P256 => "ES256", 128 - _ => unimplemented!(), 129 - }, 130 - _ => unimplemented!(), 131 - }); 132 - if !algs.contains(&alg) { 133 - return Err(Error::UnsupportedKey); 134 - } 135 - } 136 - let nonces = MemorySessionStore::<CowStr<'static>, CowStr<'static>>::default(); 137 - Ok(Self { 138 - inner: http_client, 139 - key, 140 - nonces, 141 - is_auth_server, 142 - }) 143 - } 144 - } 145 - 146 - impl<T, S> DpopClient<T, S> 147 - where 148 - S: SessionStore<CowStr<'static>, CowStr<'static>>, 149 - { 150 - fn build_proof<'s>( 151 - &self, 152 - method: CowStr<'s>, 153 - url: CowStr<'s>, 154 - ath: Option<CowStr<'s>>, 155 - nonce: Option<CowStr<'s>>, 156 - ) -> Result<CowStr<'s>> { 157 - build_dpop_proof(&self.key, method, url, nonce, ath) 158 - } 159 - fn is_use_dpop_nonce_error(&self, response: &http::Response<Vec<u8>>) -> bool { 160 - // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 161 - if self.is_auth_server { 162 - if response.status() == 400 { 163 - if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 164 - return res.error == "use_dpop_nonce"; 165 - }; 166 - } 167 - } 168 - // https://datatracker.ietf.org/doc/html/rfc6750#section-3 169 - // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 170 - else if response.status() == 401 { 171 - if let Some(www_auth) = response 172 - .headers() 173 - .get("WWW-Authenticate") 174 - .and_then(|v| v.to_str().ok()) 175 - { 176 - return www_auth.starts_with("DPoP") 177 - && www_auth.contains(r#"error="use_dpop_nonce""#); 178 - } 179 - } 180 - false 181 - } 182 - } 183 - 184 - impl<T, S> HttpClient for DpopClient<T, S> 185 - where 186 - T: HttpClient + Send + Sync + 'static, 187 - S: SessionStore<CowStr<'static>, CowStr<'static>> + Send + Sync + 'static, 188 - { 189 - type Error = Error; 190 - 191 - async fn send_http( 192 - &self, 193 - mut request: Request<Vec<u8>>, 194 - ) -> core::result::Result<Response<Vec<u8>>, Self::Error> { 195 - let uri = request.uri(); 196 - let nonce_key = CowStr::Owned(uri.authority().unwrap().to_smolstr()); 197 - let method = CowStr::Owned(request.method().to_smolstr()); 198 - let uri = CowStr::Owned(uri.to_smolstr()); 199 - // https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 200 - let ath = request 201 - .headers() 202 - .get("Authorization") 203 - .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 204 - .map(|auth| { 205 - URL_SAFE_NO_PAD 206 - .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 207 - .into() 208 - }); 209 - 210 - let init_nonce = self.nonces.get(&nonce_key).await; 211 - let init_proof = 212 - self.build_proof(method.clone(), uri.clone(), ath.clone(), init_nonce.clone())?; 213 - request.headers_mut().insert("DPoP", init_proof.parse()?); 214 - let response = self 215 - .inner 216 - .send_http(request.clone()) 217 - .await 218 - .map_err(|e| Error::Inner(e.into()))?; 219 - 220 - let next_nonce = response 221 - .headers() 222 - .get("DPoP-Nonce") 223 - .and_then(|v| v.to_str().ok()) 224 - .map(|c| CowStr::Owned(SmolStr::new(c))); 225 - match &next_nonce { 226 - Some(s) if next_nonce != init_nonce => { 227 - // Store the fresh nonce for future requests 228 - self.nonces.set(nonce_key, s.clone()).await?; 229 - } 230 - _ => { 231 - // No nonce was returned or it is the same as the one we sent. No need to 232 - // update the nonce store, or retry the request. 233 - return Ok(response); 234 - } 235 - } 236 - 237 - if !self.is_use_dpop_nonce_error(&response) { 238 - return Ok(response); 239 - } 240 - let next_proof = self.build_proof(method, uri, ath, next_nonce)?; 241 - request.headers_mut().insert("DPoP", next_proof.parse()?); 242 - let response = self 243 - .inner 244 - .send_http(request) 245 - .await 246 - .map_err(|e| Error::Inner(e.into()))?; 247 - Ok(response) 248 - } 249 - } 250 - 251 - impl<T: Clone> Clone for DpopClient<T> { 252 - fn clone(&self) -> Self { 253 - Self { 254 - inner: self.inner.clone(), 255 - key: self.key.clone(), 256 - nonces: self.nonces.clone(), 257 - is_auth_server: self.is_auth_server, 258 - } 259 - } 260 - }
+18 -2
crates/jacquard-oauth/src/error.rs
··· 1 + use jacquard_common::session::SessionStoreError; 1 2 use miette::Diagnostic; 2 - use thiserror::Error; 3 + 4 + use crate::resolver::ResolverError; 3 5 4 6 /// Errors emitted by OAuth helpers. 5 - #[derive(Debug, Error, Diagnostic)] 7 + #[derive(Debug, thiserror::Error, Diagnostic)] 6 8 pub enum OAuthError { 7 9 /// Invalid or unsupported JWK 8 10 #[error("invalid JWK: {0}")] ··· 37 39 help("PKCE must use S256; ensure verifier/challenge generated") 38 40 )] 39 41 Pkce(String), 42 + #[error("authorize error: {0}")] 43 + Authorize(String), 44 + #[error(transparent)] 45 + Atproto(#[from] crate::atproto::Error), 46 + #[error("callback error: {0}")] 47 + Callback(String), 48 + #[error(transparent)] 49 + Storage(#[from] SessionStoreError), 50 + #[error(transparent)] 51 + Session(#[from] crate::session::Error), 52 + #[error(transparent)] 53 + Request(#[from] crate::request::Error), 54 + #[error(transparent)] 55 + Client(#[from] ResolverError), 40 56 } 41 57 42 58 pub type Result<T> = core::result::Result<T, OAuthError>;
+6 -7
crates/jacquard-oauth/src/keyset.rs
··· 1 1 use crate::jose::create_signed_jwt; 2 2 use crate::jose::jws::RegisteredHeader; 3 3 use crate::jose::jwt::Claims; 4 - use jacquard_common::CowStr; 4 + use jacquard_common::{CowStr, IntoStatic}; 5 5 use jose_jwa::{Algorithm, Signing}; 6 6 use jose_jwk::{Class, EcCurves, crypto}; 7 7 use jose_jwk::{Jwk, JwkSet, Key}; 8 - use smol_str::{SmolStr, ToSmolStr}; 9 8 use std::collections::HashSet; 10 9 use thiserror::Error; 11 10 ··· 18 17 #[error("key must have a `kid`")] 19 18 EmptyKid, 20 19 #[error("no signing key found for algorithms: {0:?}")] 21 - NotFound(Vec<SmolStr>), 20 + NotFound(Vec<CowStr<'static>>), 22 21 #[error("key for signing must be a secret key")] 23 22 PublicKey, 24 23 #[error("crypto error: {0:?}")] ··· 49 48 } 50 49 JwkSet { keys } 51 50 } 52 - pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result<CowStr<'static>> { 51 + pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> { 53 52 let Some(jwk) = self.find_key(algs, Class::Signing) else { 54 - return Err(Error::NotFound(algs.to_vec())); 53 + return Err(Error::NotFound(algs.to_vec().into_static())); 55 54 }; 56 55 self.create_jwt_with_key(jwk, claims) 57 56 } 58 - fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> { 57 + fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> { 59 58 let candidates = self 60 59 .0 61 60 .iter() ··· 70 69 }, 71 70 _ => unimplemented!(), 72 71 }; 73 - Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr())) 72 + Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg))) 74 73 }) 75 74 .collect::<Vec<_>>(); 76 75 for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
+4
crates/jacquard-oauth/src/lib.rs
··· 2 2 //! Transport, discovery, and orchestration live in `jacquard`. 3 3 4 4 pub mod atproto; 5 + pub mod authstore; 6 + pub mod client; 5 7 pub mod dpop; 6 8 pub mod error; 7 9 pub mod jose; 8 10 pub mod keyset; 11 + pub mod request; 9 12 pub mod resolver; 10 13 pub mod scopes; 11 14 pub mod session; 12 15 pub mod types; 16 + pub mod utils; 13 17 14 18 pub const FALLBACK_ALG: &str = "ES256";
+536
crates/jacquard-oauth/src/request.rs
··· 1 + use chrono::{DateTime, FixedOffset, TimeDelta, Utc}; 2 + use http::{Method, Request, StatusCode}; 3 + use jacquard_common::{ 4 + CowStr, IntoStatic, 5 + cowstr::ToCowStr, 6 + http_client::HttpClient, 7 + ident_resolver::{IdentityError, IdentityResolver}, 8 + session::SessionStoreError, 9 + types::{ 10 + did::Did, 11 + string::{AtStrError, Datetime}, 12 + }, 13 + }; 14 + use jose_jwk::Key; 15 + use serde::{Serialize, de::DeserializeOwned}; 16 + use serde_json::Value; 17 + use smol_str::ToSmolStr; 18 + use std::sync::Arc; 19 + use thiserror::Error; 20 + use url::Url; 21 + 22 + use crate::{ 23 + FALLBACK_ALG, 24 + atproto::{AtprotoClientMetadata, atproto_client_metadata}, 25 + dpop::{DpopClient, DpopExt}, 26 + jose::jwt::{RegisteredClaims, RegisteredClaimsAud}, 27 + keyset::Keyset, 28 + resolver::OAuthResolver, 29 + scopes::Scope, 30 + session::{ 31 + AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData, 32 + }, 33 + types::{ 34 + AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt, 35 + OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse, 36 + OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters, 37 + TokenGrantType, TokenRequestParameters, TokenSet, 38 + }, 39 + utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce}, 40 + }; 41 + 42 + // https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 43 + const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = 44 + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 45 + 46 + #[derive(Error, Debug)] 47 + pub enum Error { 48 + #[error("no {0} endpoint available")] 49 + NoEndpoint(CowStr<'static>), 50 + #[error("token response verification failed")] 51 + Token(CowStr<'static>), 52 + #[error("unsupported authentication method")] 53 + UnsupportedAuthMethod, 54 + #[error("no refresh token available")] 55 + TokenRefresh, 56 + #[error("failed to parse DID: {0}")] 57 + InvalidDid(#[from] AtStrError), 58 + #[error(transparent)] 59 + DpopClient(#[from] crate::dpop::Error), 60 + #[error(transparent)] 61 + Storage(#[from] SessionStoreError), 62 + 63 + #[error(transparent)] 64 + ResolverError(#[from] crate::resolver::ResolverError), 65 + // #[error(transparent)] 66 + // OAuthSession(#[from] crate::oauth_session::Error), 67 + #[error(transparent)] 68 + Http(#[from] http::Error), 69 + #[error("http client error: {0}")] 70 + HttpClient(Box<dyn std::error::Error + Send + Sync + 'static>), 71 + #[error("http status: {0}")] 72 + HttpStatus(StatusCode), 73 + #[error("http status: {0}, body: {1:?}")] 74 + HttpStatusWithBody(StatusCode, Value), 75 + #[error(transparent)] 76 + Identity(#[from] IdentityError), 77 + #[error(transparent)] 78 + Keyset(#[from] crate::keyset::Error), 79 + #[error(transparent)] 80 + SerdeHtmlForm(#[from] serde_html_form::ser::Error), 81 + #[error(transparent)] 82 + SerdeJson(#[from] serde_json::Error), 83 + #[error(transparent)] 84 + Atproto(#[from] crate::atproto::Error), 85 + } 86 + 87 + pub type Result<T> = core::result::Result<T, Error>; 88 + 89 + #[allow(dead_code)] 90 + pub enum OAuthRequest<'a> { 91 + Token(TokenRequestParameters<'a>), 92 + Refresh(RefreshRequestParameters<'a>), 93 + Revocation(RevocationRequestParameters<'a>), 94 + Introspection, 95 + PushedAuthorizationRequest(ParParameters<'a>), 96 + } 97 + 98 + impl OAuthRequest<'_> { 99 + pub fn name(&self) -> CowStr<'static> { 100 + CowStr::new_static(match self { 101 + Self::Token(_) => "token", 102 + Self::Refresh(_) => "refresh", 103 + Self::Revocation(_) => "revocation", 104 + Self::Introspection => "introspection", 105 + Self::PushedAuthorizationRequest(_) => "pushed_authorization_request", 106 + }) 107 + } 108 + pub fn expected_status(&self) -> StatusCode { 109 + match self { 110 + Self::Token(_) | Self::Refresh(_) => StatusCode::OK, 111 + Self::PushedAuthorizationRequest(_) => StatusCode::CREATED, 112 + // Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`. 113 + Self::Revocation(_) => StatusCode::NO_CONTENT, 114 + _ => unimplemented!(), 115 + } 116 + } 117 + } 118 + 119 + #[derive(Debug, Serialize)] 120 + pub struct RequestPayload<'a, T> 121 + where 122 + T: Serialize, 123 + { 124 + client_id: CowStr<'a>, 125 + #[serde(skip_serializing_if = "Option::is_none")] 126 + client_assertion_type: Option<CowStr<'a>>, 127 + #[serde(skip_serializing_if = "Option::is_none")] 128 + client_assertion: Option<CowStr<'a>>, 129 + #[serde(flatten)] 130 + parameters: T, 131 + } 132 + 133 + #[derive(Debug, Clone)] 134 + pub struct OAuthMetadata { 135 + pub server_metadata: OAuthAuthorizationServerMetadata<'static>, 136 + pub client_metadata: OAuthClientMetadata<'static>, 137 + pub keyset: Option<Keyset>, 138 + } 139 + 140 + impl OAuthMetadata { 141 + pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>( 142 + client: &T, 143 + ClientData { keyset, config }: &ClientData<'r>, 144 + session_data: &ClientSessionData<'r>, 145 + ) -> Result<Self> { 146 + Ok(OAuthMetadata { 147 + server_metadata: client 148 + .get_authorization_server_metadata(&session_data.authserver_url) 149 + .await?, 150 + client_metadata: atproto_client_metadata(config.clone(), &keyset) 151 + .unwrap() 152 + .into_static(), 153 + keyset: keyset.clone(), 154 + }) 155 + } 156 + } 157 + 158 + pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( 159 + client: &T, 160 + login_hint: Option<CowStr<'r>>, 161 + prompt: Option<AuthorizeOptionPrompt>, 162 + metadata: &OAuthMetadata, 163 + ) -> crate::request::Result<AuthRequestData<'r>> { 164 + let state = generate_nonce(); 165 + let (code_challenge, verifier) = generate_pkce(); 166 + 167 + let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { 168 + return Err(Error::Token("none of the algorithms worked".into())); 169 + }; 170 + let mut dpop_data = DpopReqData { 171 + dpop_key, 172 + dpop_authserver_nonce: None, 173 + }; 174 + let parameters = ParParameters { 175 + response_type: AuthorizationResponseType::Code, 176 + redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(), 177 + state: state.clone(), 178 + scope: metadata.client_metadata.scope.clone(), 179 + response_mode: None, 180 + code_challenge, 181 + code_challenge_method: AuthorizationCodeChallengeMethod::S256, 182 + login_hint: login_hint, 183 + prompt: prompt.map(CowStr::from), 184 + }; 185 + if metadata 186 + .server_metadata 187 + .pushed_authorization_request_endpoint 188 + .is_some() 189 + { 190 + let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>( 191 + &client, 192 + &mut dpop_data, 193 + OAuthRequest::PushedAuthorizationRequest(parameters), 194 + metadata, 195 + ) 196 + .await?; 197 + 198 + let scopes = if let Some(scope) = &metadata.client_metadata.scope { 199 + Scope::parse_multiple_reduced(&scope) 200 + .expect("Failed to parse scopes") 201 + .into_static() 202 + } else { 203 + vec![] 204 + }; 205 + let auth_req_data = AuthRequestData { 206 + state, 207 + authserver_url: url::Url::parse(&metadata.server_metadata.issuer) 208 + .expect("Failed to parse issuer URL"), 209 + account_did: None, 210 + scopes, 211 + request_uri: par_response.request_uri.to_cowstr().into_static(), 212 + authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(), 213 + authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(), 214 + pkce_verifier: verifier, 215 + dpop_data, 216 + }; 217 + 218 + Ok(auth_req_data) 219 + } else if metadata 220 + .server_metadata 221 + .require_pushed_authorization_requests 222 + == Some(true) 223 + { 224 + Err(Error::NoEndpoint(CowStr::new_static( 225 + "server requires PAR but no endpoint is available", 226 + ))) 227 + } else { 228 + todo!("use of PAR is mandatory") 229 + } 230 + } 231 + 232 + pub async fn refresh<'r, T>( 233 + client: &T, 234 + mut session_data: ClientSessionData<'r>, 235 + metadata: &OAuthMetadata, 236 + ) -> Result<ClientSessionData<'r>> 237 + where 238 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 239 + { 240 + let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else { 241 + return Err(Error::TokenRefresh); 242 + }; 243 + 244 + // /!\ IMPORTANT /!\ 245 + // 246 + // The "sub" MUST be a DID, whose issuer authority is indeed the server we 247 + // are trying to obtain credentials from. Note that we are doing this 248 + // *before* we actually try to refresh the token: 249 + // 1) To avoid unnecessary refresh 250 + // 2) So that the refresh is the last async operation, ensuring as few 251 + // async operations happen before the result gets a chance to be stored. 252 + let aud = client 253 + .verify_issuer(&metadata.server_metadata, &session_data.token_set.sub) 254 + .await?; 255 + let iss = metadata.server_metadata.issuer.clone(); 256 + 257 + let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>( 258 + client, 259 + &mut session_data.dpop_data, 260 + OAuthRequest::Refresh(RefreshRequestParameters { 261 + grant_type: TokenGrantType::RefreshToken, 262 + refresh_token: refresh_token.clone(), 263 + scope: None, 264 + }), 265 + metadata, 266 + ) 267 + .await?; 268 + 269 + let expires_at = response.expires_in.and_then(|expires_in| { 270 + let now = Datetime::now(); 271 + now.as_ref() 272 + .checked_add_signed(TimeDelta::seconds(expires_in)) 273 + .map(Datetime::new) 274 + }); 275 + 276 + session_data.update_with_tokens(TokenSet { 277 + iss, 278 + sub: session_data.token_set.sub.clone(), 279 + aud: CowStr::Owned(aud.to_smolstr()), 280 + scope: response.scope.map(CowStr::Owned), 281 + access_token: CowStr::Owned(response.access_token), 282 + refresh_token: response.refresh_token.map(CowStr::Owned), 283 + token_type: response.token_type, 284 + expires_at, 285 + }); 286 + 287 + Ok(session_data) 288 + } 289 + 290 + pub async fn exchange_code<'r, T, D>( 291 + client: &T, 292 + data_source: &'r mut D, 293 + code: &str, 294 + verifier: &str, 295 + metadata: &OAuthMetadata, 296 + ) -> Result<TokenSet<'r>> 297 + where 298 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 299 + D: DpopDataSource, 300 + { 301 + let token_response = oauth_request::<OAuthTokenResponse, T, D>( 302 + client, 303 + data_source, 304 + OAuthRequest::Token(TokenRequestParameters { 305 + grant_type: TokenGrantType::AuthorizationCode, 306 + code: code.into(), 307 + redirect_uri: CowStr::Owned( 308 + metadata.client_metadata.redirect_uris[0] 309 + .clone() 310 + .to_smolstr(), 311 + ), // ? 312 + code_verifier: verifier.into(), 313 + }), 314 + metadata, 315 + ) 316 + .await?; 317 + let Some(sub) = token_response.sub else { 318 + return Err(Error::Token("missing `sub` in token response".into())); 319 + }; 320 + let sub = Did::new_owned(sub)?; 321 + let iss = metadata.server_metadata.issuer.clone(); 322 + // /!\ IMPORTANT /!\ 323 + // 324 + // The token_response MUST always be valid before the "sub" it contains 325 + // can be trusted (see Atproto's OAuth spec for details). 326 + let aud = client 327 + .verify_issuer(&metadata.server_metadata, &sub) 328 + .await?; 329 + 330 + let expires_at = token_response.expires_in.and_then(|expires_in| { 331 + Datetime::now() 332 + .as_ref() 333 + .checked_add_signed(TimeDelta::seconds(expires_in)) 334 + .map(Datetime::new) 335 + }); 336 + Ok(TokenSet { 337 + iss, 338 + sub, 339 + aud: CowStr::Owned(aud.to_smolstr()), 340 + scope: token_response.scope.map(CowStr::Owned), 341 + access_token: CowStr::Owned(token_response.access_token), 342 + refresh_token: token_response.refresh_token.map(CowStr::Owned), 343 + token_type: token_response.token_type, 344 + expires_at, 345 + }) 346 + } 347 + 348 + pub async fn revoke<'r, T, D>( 349 + client: &T, 350 + data_source: &'r mut D, 351 + token: &str, 352 + metadata: &OAuthMetadata, 353 + ) -> Result<()> 354 + where 355 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 356 + D: DpopDataSource, 357 + { 358 + oauth_request::<(), T, D>( 359 + client, 360 + data_source, 361 + OAuthRequest::Revocation(RevocationRequestParameters { 362 + token: token.into(), 363 + }), 364 + metadata, 365 + ) 366 + .await?; 367 + Ok(()) 368 + } 369 + 370 + pub async fn oauth_request<'de: 'r, 'r, O, T, D>( 371 + client: &T, 372 + data_source: &'r mut D, 373 + request: OAuthRequest<'r>, 374 + metadata: &OAuthMetadata, 375 + ) -> Result<O> 376 + where 377 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 378 + O: serde::de::DeserializeOwned, 379 + D: DpopDataSource, 380 + { 381 + let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else { 382 + return Err(Error::NoEndpoint(request.name())); 383 + }; 384 + let client_assertions = build_auth( 385 + metadata.keyset.as_ref(), 386 + &metadata.server_metadata, 387 + &metadata.client_metadata, 388 + )?; 389 + let body = match &request { 390 + OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?, 391 + OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?, 392 + OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?, 393 + OAuthRequest::PushedAuthorizationRequest(params) => { 394 + build_oauth_req_body(client_assertions, params)? 395 + } 396 + _ => unimplemented!(), 397 + }; 398 + let req = Request::builder() 399 + .uri(url.to_string()) 400 + .method(Method::POST) 401 + .header("Content-Type", "application/x-www-form-urlencoded") 402 + .body(body.into_bytes())?; 403 + let res = client 404 + .dpop_server_call(data_source) 405 + .send(req) 406 + .await 407 + .map_err(Error::DpopClient)?; 408 + if res.status() == request.expected_status() { 409 + let body = res.body(); 410 + if body.is_empty() { 411 + // since an empty body cannot be deserialized, use “null” temporarily to allow deserialization to `()`. 412 + Ok(serde_json::from_slice(b"null")?) 413 + } else { 414 + let output: O = serde_json::from_slice(body)?; 415 + Ok(output) 416 + } 417 + } else if res.status().is_client_error() { 418 + Err(Error::HttpStatusWithBody( 419 + res.status(), 420 + serde_json::from_slice(res.body())?, 421 + )) 422 + } else { 423 + Err(Error::HttpStatus(res.status())) 424 + } 425 + } 426 + 427 + fn endpoint_for_req<'a, 'r>( 428 + server_metadata: &'r OAuthAuthorizationServerMetadata<'a>, 429 + request: &'r OAuthRequest, 430 + ) -> Option<&'r CowStr<'a>> { 431 + match request { 432 + OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint), 433 + OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(), 434 + OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(), 435 + OAuthRequest::PushedAuthorizationRequest(_) => server_metadata 436 + .pushed_authorization_request_endpoint 437 + .as_ref(), 438 + } 439 + } 440 + 441 + fn build_oauth_req_body<'a, S>( 442 + client_assertions: ClientAssertions<'a>, 443 + parameters: S, 444 + ) -> Result<String> 445 + where 446 + S: Serialize, 447 + { 448 + Ok(serde_html_form::to_string(RequestPayload { 449 + client_id: client_assertions.client_id, 450 + client_assertion_type: client_assertions.assertion_type, 451 + client_assertion: client_assertions.assertion, 452 + parameters, 453 + })?) 454 + } 455 + 456 + #[derive(Debug, Clone, Default)] 457 + pub struct ClientAssertions<'a> { 458 + client_id: CowStr<'a>, 459 + assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER` 460 + assertion: Option<CowStr<'a>>, 461 + } 462 + 463 + impl<'s> ClientAssertions<'s> { 464 + pub fn new_id(client_id: CowStr<'s>) -> Self { 465 + Self { 466 + client_id, 467 + assertion_type: None, 468 + assertion: None, 469 + } 470 + } 471 + } 472 + 473 + fn build_auth<'a>( 474 + keyset: Option<&Keyset>, 475 + server_metadata: &OAuthAuthorizationServerMetadata<'a>, 476 + client_metadata: &OAuthClientMetadata<'a>, 477 + ) -> Result<ClientAssertions<'a>> { 478 + let method_supported = server_metadata 479 + .token_endpoint_auth_methods_supported 480 + .as_ref(); 481 + 482 + let client_id = client_metadata.client_id.to_cowstr().into_static(); 483 + if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() { 484 + match (*method).as_ref() { 485 + "private_key_jwt" 486 + if method_supported 487 + .as_ref() 488 + .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) => 489 + { 490 + if let Some(keyset) = &keyset { 491 + let mut algs = server_metadata 492 + .token_endpoint_auth_signing_alg_values_supported 493 + .clone() 494 + .unwrap_or(vec![FALLBACK_ALG.into()]); 495 + algs.sort_by(compare_algos); 496 + let iat = Utc::now().timestamp(); 497 + return Ok(ClientAssertions { 498 + client_id: client_id.clone(), 499 + assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)), 500 + assertion: Some( 501 + keyset.create_jwt( 502 + &algs, 503 + // https://datatracker.ietf.org/doc/html/rfc7523#section-3 504 + RegisteredClaims { 505 + iss: Some(client_id.clone()), 506 + sub: Some(client_id), 507 + aud: Some(RegisteredClaimsAud::Single( 508 + server_metadata.issuer.clone(), 509 + )), 510 + exp: Some(iat + 60), 511 + // "iat" is required and **MUST** be less than one minute 512 + // https://datatracker.ietf.org/doc/html/rfc9101 513 + iat: Some(iat), 514 + // atproto oauth-provider requires "jti" to be present 515 + jti: Some(generate_nonce()), 516 + ..Default::default() 517 + } 518 + .into(), 519 + )?, 520 + ), 521 + }); 522 + } 523 + } 524 + "none" 525 + if method_supported 526 + .as_ref() 527 + .is_some_and(|v| v.contains(&CowStr::new_static("none"))) => 528 + { 529 + return Ok(ClientAssertions::new_id(client_id)); 530 + } 531 + _ => {} 532 + } 533 + } 534 + 535 + Err(Error::UnsupportedAuthMethod) 536 + }
+17
crates/jacquard-oauth/src/resolver.rs
··· 5 5 use jacquard_common::types::did_doc::DidDocument; 6 6 use jacquard_common::types::ident::AtIdentifier; 7 7 use jacquard_common::{http_client::HttpClient, types::did::Did}; 8 + use sha2::digest::const_oid::Arc; 8 9 use url::Url; 9 10 10 11 #[derive(thiserror::Error, Debug, miette::Diagnostic)] ··· 41 42 42 43 #[async_trait::async_trait] 43 44 pub trait OAuthResolver: IdentityResolver + HttpClient { 45 + async fn verify_issuer( 46 + &self, 47 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 48 + sub: &Did<'_>, 49 + ) -> Result<Url, ResolverError> { 50 + let (metadata, identity) = self.resolve_from_identity(sub).await?; 51 + if metadata.issuer != server_metadata.issuer { 52 + return Err(ResolverError::Did(format!("DIDs did not match"))); 53 + } 54 + Ok(identity 55 + .pds_endpoint() 56 + .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 57 + } 44 58 async fn resolve_oauth( 45 59 &self, 46 60 input: &str, ··· 146 160 Ok(as_metadata) 147 161 } 148 162 } 163 + 164 + #[async_trait::async_trait] 165 + impl<T: OAuthResolver + Sync + Send> OAuthResolver for std::sync::Arc<T> {} 149 166 150 167 pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 151 168 client: &T,
+41 -1
crates/jacquard-oauth/src/scopes.rs
··· 26 26 use jacquard_common::types::nsid::Nsid; 27 27 use jacquard_common::types::string::AtStrError; 28 28 use jacquard_common::{CowStr, IntoStatic}; 29 + use serde::de::Visitor; 30 + use serde::{Deserialize, Serialize}; 29 31 use smol_str::{SmolStr, ToSmolStr}; 30 32 31 33 /// Represents an AT Protocol OAuth scope ··· 51 53 Profile, 52 54 /// Email scope - access to user email address 53 55 Email, 56 + } 57 + 58 + impl Serialize for Scope<'_> { 59 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 60 + where 61 + S: serde::Serializer, 62 + { 63 + serializer.serialize_str(&self.to_string_normalized()) 64 + } 65 + } 66 + 67 + impl<'de> Deserialize<'de> for Scope<'_> { 68 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 69 + where 70 + D: serde::Deserializer<'de>, 71 + { 72 + struct ScopeVisitor; 73 + 74 + impl Visitor<'_> for ScopeVisitor { 75 + type Value = Scope<'static>; 76 + 77 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 78 + write!(formatter, "a scope string") 79 + } 80 + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 81 + where 82 + E: serde::de::Error, 83 + { 84 + Scope::parse(v) 85 + .map(|s| s.into_static()) 86 + .map_err(|e| serde::de::Error::custom(format!("{:?}", e))) 87 + } 88 + } 89 + deserializer.deserialize_str(ScopeVisitor) 90 + } 54 91 } 55 92 56 93 impl IntoStatic for Scope<'_> { ··· 370 407 return CowStr::default(); 371 408 } 372 409 373 - let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect(); 410 + let mut serialized: Vec<String> = scopes 411 + .iter() 412 + .map(|scope| scope.to_string_normalized()) 413 + .collect(); 374 414 375 415 serialized.sort(); 376 416 serialized.join(" ").into()
+326 -8
crates/jacquard-oauth/src/session.rs
··· 1 - use crate::types::TokenSet; 1 + use std::sync::Arc; 2 + 3 + use crate::{ 4 + atproto::{AtprotoClientMetadata, atproto_client_metadata}, 5 + authstore::ClientAuthStore, 6 + dpop::DpopExt, 7 + keyset::Keyset, 8 + request::{OAuthMetadata, refresh}, 9 + resolver::OAuthResolver, 10 + scopes::Scope, 11 + types::TokenSet, 12 + }; 2 13 3 - use jacquard_common::IntoStatic; 14 + use dashmap::DashMap; 15 + use jacquard_common::{ 16 + CowStr, IntoStatic, 17 + http_client::HttpClient, 18 + session::SessionStoreError, 19 + types::{did::Did, string::Datetime}, 20 + }; 4 21 use jose_jwk::Key; 5 22 use serde::{Deserialize, Serialize}; 23 + use smol_str::{SmolStr, format_smolstr}; 24 + use tokio::sync::Mutex; 25 + use url::Url; 26 + 27 + pub trait DpopDataSource { 28 + fn key(&self) -> &Key; 29 + fn authserver_nonce(&self) -> Option<CowStr<'_>>; 30 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>); 31 + fn host_nonce(&self) -> Option<CowStr<'_>>; 32 + fn set_host_nonce(&mut self, nonce: CowStr<'_>); 33 + } 6 34 35 + /// Persisted information about an OAuth session. Used to resume an active session. 7 36 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 8 - pub struct OauthSession<'s> { 37 + pub struct ClientSessionData<'s> { 38 + // Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this information. 39 + #[serde(borrow)] 40 + pub account_did: Did<'s>, 41 + 42 + // Identifier to distinguish this particular session for the account. Server backends generally support multiple sessions for the same account. This package will re-use the random 'state' token from the auth flow as the session ID. 43 + pub session_id: CowStr<'s>, 44 + 45 + // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info. 46 + pub host_url: Url, 47 + 48 + // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info. 49 + pub authserver_url: Url, 50 + 51 + // Full token endpoint 52 + pub authserver_token_endpoint: CowStr<'s>, 53 + 54 + // Full revocation endpoint, if it exists 55 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 56 + pub authserver_revocation_endpoint: Option<CowStr<'s>>, 57 + 58 + // The set of scopes approved for this session (returned in the initial token request) 59 + pub scopes: Vec<Scope<'s>>, 60 + 61 + #[serde(flatten)] 62 + pub dpop_data: DpopClientData<'s>, 63 + 64 + #[serde(flatten)] 65 + pub token_set: TokenSet<'s>, 66 + } 67 + 68 + impl IntoStatic for ClientSessionData<'_> { 69 + type Output = ClientSessionData<'static>; 70 + 71 + fn into_static(self) -> Self::Output { 72 + ClientSessionData { 73 + authserver_url: self.authserver_url, 74 + authserver_token_endpoint: self.authserver_token_endpoint.into_static(), 75 + authserver_revocation_endpoint: self 76 + .authserver_revocation_endpoint 77 + .map(IntoStatic::into_static), 78 + scopes: self.scopes.into_static(), 79 + dpop_data: self.dpop_data.into_static(), 80 + token_set: self.token_set.into_static(), 81 + account_did: self.account_did.into_static(), 82 + session_id: self.session_id.into_static(), 83 + host_url: self.host_url, 84 + } 85 + } 86 + } 87 + 88 + impl ClientSessionData<'_> { 89 + pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) { 90 + if let Some(Ok(scopes)) = token_set 91 + .scope 92 + .as_ref() 93 + .map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static)) 94 + { 95 + self.scopes = scopes; 96 + } 97 + self.token_set = token_set.into_static(); 98 + } 99 + } 100 + 101 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 102 + pub struct DpopClientData<'s> { 9 103 pub dpop_key: Key, 104 + // Current auth server DPoP nonce 10 105 #[serde(borrow)] 11 - pub token_set: TokenSet<'s>, 106 + pub dpop_authserver_nonce: CowStr<'s>, 107 + // Current host ("resource server", eg PDS) DPoP nonce 108 + pub dpop_host_nonce: CowStr<'s>, 12 109 } 13 110 14 - impl IntoStatic for OauthSession<'_> { 15 - type Output = OauthSession<'static>; 111 + impl IntoStatic for DpopClientData<'_> { 112 + type Output = DpopClientData<'static>; 16 113 17 114 fn into_static(self) -> Self::Output { 18 - OauthSession { 115 + DpopClientData { 19 116 dpop_key: self.dpop_key, 20 - token_set: self.token_set.into_static(), 117 + dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(), 118 + dpop_host_nonce: self.dpop_host_nonce.into_static(), 21 119 } 22 120 } 23 121 } 122 + 123 + impl DpopDataSource for DpopClientData<'_> { 124 + fn key(&self) -> &Key { 125 + &self.dpop_key 126 + } 127 + fn authserver_nonce(&self) -> Option<CowStr<'_>> { 128 + Some(self.dpop_authserver_nonce.clone()) 129 + } 130 + 131 + fn host_nonce(&self) -> Option<CowStr<'_>> { 132 + Some(self.dpop_host_nonce.clone()) 133 + } 134 + 135 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) { 136 + self.dpop_authserver_nonce = nonce.into_static(); 137 + } 138 + 139 + fn set_host_nonce(&mut self, nonce: CowStr<'_>) { 140 + self.dpop_host_nonce = nonce.into_static(); 141 + } 142 + } 143 + 144 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 145 + pub struct AuthRequestData<'s> { 146 + // The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information. 147 + #[serde(borrow)] 148 + pub state: CowStr<'s>, 149 + 150 + // URL of the auth server (eg, PDS or entryway) 151 + pub authserver_url: Url, 152 + 153 + // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response. 154 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 155 + pub account_did: Option<Did<'s>>, 156 + 157 + // OAuth scope strings 158 + pub scopes: Vec<Scope<'s>>, 159 + 160 + // unique token in URI format, which will be used by the client in the auth flow redirect 161 + pub request_uri: CowStr<'s>, 162 + 163 + // Full token endpoint URL 164 + pub authserver_token_endpoint: CowStr<'s>, 165 + 166 + // Full revocation endpoint, if it exists 167 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 168 + pub authserver_revocation_endpoint: Option<CowStr<'s>>, 169 + 170 + // The secret token/nonce which a code challenge was generated from 171 + pub pkce_verifier: CowStr<'s>, 172 + 173 + #[serde(flatten)] 174 + pub dpop_data: DpopReqData<'s>, 175 + } 176 + 177 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 178 + pub struct DpopReqData<'s> { 179 + // The secret cryptographic key generated by the client for this specific OAuth session 180 + pub dpop_key: Key, 181 + // Server-provided DPoP nonce from auth request (PAR) 182 + #[serde(borrow)] 183 + pub dpop_authserver_nonce: Option<CowStr<'s>>, 184 + } 185 + 186 + impl DpopDataSource for DpopReqData<'_> { 187 + fn key(&self) -> &Key { 188 + &self.dpop_key 189 + } 190 + fn authserver_nonce(&self) -> Option<CowStr<'_>> { 191 + self.dpop_authserver_nonce.clone() 192 + } 193 + 194 + fn host_nonce(&self) -> Option<CowStr<'_>> { 195 + None 196 + } 197 + 198 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) { 199 + self.dpop_authserver_nonce = Some(nonce.into_static()); 200 + } 201 + 202 + fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {} 203 + } 204 + 205 + #[derive(Clone, Debug)] 206 + pub struct ClientData<'s> { 207 + pub keyset: Option<Keyset>, 208 + pub config: AtprotoClientMetadata<'s>, 209 + } 210 + 211 + pub struct ClientSession<'s> { 212 + pub keyset: Option<Keyset>, 213 + pub config: AtprotoClientMetadata<'s>, 214 + pub session_data: ClientSessionData<'s>, 215 + } 216 + 217 + impl<'s> ClientSession<'s> { 218 + pub fn new( 219 + ClientData { keyset, config }: ClientData<'s>, 220 + session_data: ClientSessionData<'s>, 221 + ) -> Self { 222 + Self { 223 + keyset, 224 + config, 225 + session_data, 226 + } 227 + } 228 + 229 + pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>( 230 + &self, 231 + client: &T, 232 + ) -> Result<OAuthMetadata, Error> { 233 + Ok(OAuthMetadata { 234 + server_metadata: client 235 + .get_authorization_server_metadata(&self.session_data.authserver_url) 236 + .await 237 + .map_err(|e| Error::ServerAgent(crate::request::Error::ResolverError(e)))?, 238 + client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset) 239 + .unwrap() 240 + .into_static(), 241 + keyset: self.keyset.clone(), 242 + }) 243 + } 244 + } 245 + 246 + #[derive(thiserror::Error, Debug)] 247 + pub enum Error { 248 + #[error(transparent)] 249 + ServerAgent(#[from] crate::request::Error), 250 + #[error(transparent)] 251 + Store(#[from] SessionStoreError), 252 + #[error("session does not exist")] 253 + SessionNotFound, 254 + } 255 + 256 + pub struct SessionRegistry<T, S> 257 + where 258 + T: OAuthResolver, 259 + S: ClientAuthStore, 260 + { 261 + pub store: Arc<S>, 262 + pub client: Arc<T>, 263 + pub client_data: ClientData<'static>, 264 + pending: DashMap<SmolStr, Arc<Mutex<()>>>, 265 + } 266 + 267 + impl<T, S> SessionRegistry<T, S> 268 + where 269 + S: ClientAuthStore, 270 + T: OAuthResolver, 271 + { 272 + pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self { 273 + let store = Arc::new(store); 274 + Self { 275 + store: Arc::clone(&store), 276 + client, 277 + client_data, 278 + pending: DashMap::new(), 279 + } 280 + } 281 + } 282 + 283 + impl<T, S> SessionRegistry<T, S> 284 + where 285 + S: ClientAuthStore + Send + Sync + 'static, 286 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 287 + { 288 + async fn get_refreshed( 289 + &self, 290 + did: &Did<'_>, 291 + session_id: &str, 292 + ) -> Result<ClientSessionData<'_>, Error> { 293 + let key = format_smolstr!("{}_{}", did, session_id); 294 + let lock = self 295 + .pending 296 + .entry(key) 297 + .or_insert_with(|| Arc::new(Mutex::new(()))) 298 + .clone(); 299 + let _guard = lock.lock().await; 300 + 301 + let mut session = self 302 + .store 303 + .get_session(did, session_id) 304 + .await? 305 + .ok_or(Error::SessionNotFound)?; 306 + if let Some(expires_at) = &session.token_set.expires_at { 307 + if expires_at > &Datetime::now() { 308 + return Ok(session); 309 + } 310 + } 311 + let metadata = OAuthMetadata::new(&self.client, &self.client_data, &session).await?; 312 + session = refresh(self.client.as_ref(), session, &metadata).await?; 313 + self.store.upsert_session(session.clone()).await?; 314 + 315 + Ok(session) 316 + } 317 + pub async fn get( 318 + &self, 319 + did: &Did<'_>, 320 + session_id: &str, 321 + refresh: bool, 322 + ) -> Result<ClientSessionData<'_>, Error> { 323 + if refresh { 324 + self.get_refreshed(did, session_id).await 325 + } else { 326 + // TODO: cached? 327 + self.store 328 + .get_session(did, session_id) 329 + .await? 330 + .ok_or(Error::SessionNotFound) 331 + } 332 + } 333 + pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> { 334 + self.store.upsert_session(value).await?; 335 + Ok(()) 336 + } 337 + pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> { 338 + self.store.delete_session(did, session_id).await?; 339 + Ok(()) 340 + } 341 + }
+3 -2
crates/jacquard-oauth/src/types.rs
··· 13 13 pub use self::token::*; 14 14 use jacquard_common::CowStr; 15 15 use serde::Deserialize; 16 + use url::Url; 16 17 17 - #[derive(Debug, Deserialize)] 18 + #[derive(Debug, Deserialize, Clone, Copy)] 18 19 pub enum AuthorizeOptionPrompt { 19 20 Login, 20 21 None, ··· 35 36 36 37 #[derive(Debug)] 37 38 pub struct AuthorizeOptions<'s> { 38 - pub redirect_uri: Option<CowStr<'s>>, 39 + pub redirect_uri: Option<Url>, 39 40 pub scopes: Vec<Scope<'s>>, 40 41 pub prompt: Option<AuthorizeOptionPrompt>, 41 42 pub state: Option<CowStr<'s>>,
+3 -3
crates/jacquard-oauth/src/types/request.rs
··· 27 27 } 28 28 29 29 #[derive(Serialize, Deserialize)] 30 - pub struct PushedAuthorizationRequestParameters<'a> { 30 + pub struct ParParameters<'a> { 31 31 // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 32 32 pub response_type: AuthorizationResponseType, 33 33 #[serde(borrow)] ··· 115 115 } 116 116 } 117 117 118 - impl IntoStatic for PushedAuthorizationRequestParameters<'_> { 119 - type Output = PushedAuthorizationRequestParameters<'static>; 118 + impl IntoStatic for ParParameters<'_> { 119 + type Output = ParParameters<'static>; 120 120 121 121 fn into_static(self) -> Self::Output { 122 122 Self::Output {
+8 -36
crates/jacquard-oauth/src/types/response.rs
··· 1 - use jacquard_common::{CowStr, IntoStatic}; 2 1 use serde::{Deserialize, Serialize}; 2 + use smol_str::SmolStr; 3 3 4 4 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 5 - pub struct OAuthParResponse<'r> { 6 - #[serde(borrow)] 7 - pub request_uri: CowStr<'r>, 5 + pub struct OAuthParResponse { 6 + pub request_uri: SmolStr, 8 7 pub expires_in: Option<u32>, 9 8 } 10 9 ··· 16 15 17 16 // https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 18 17 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 19 - pub struct OAuthTokenResponse<'r> { 20 - #[serde(borrow)] 21 - pub access_token: CowStr<'r>, 18 + pub struct OAuthTokenResponse { 19 + pub access_token: SmolStr, 22 20 pub token_type: OAuthTokenType, 23 21 pub expires_in: Option<i64>, 24 - pub refresh_token: Option<CowStr<'r>>, 25 - pub scope: Option<CowStr<'r>>, 22 + pub refresh_token: Option<SmolStr>, 23 + pub scope: Option<SmolStr>, 26 24 // ATPROTO extension: add the sub claim to the token response to allow 27 25 // clients to resolve the PDS url (audience) using the did resolution 28 26 // mechanism. 29 - pub sub: Option<CowStr<'r>>, 30 - } 31 - 32 - impl IntoStatic for OAuthTokenResponse<'_> { 33 - type Output = OAuthTokenResponse<'static>; 34 - 35 - fn into_static(self) -> Self::Output { 36 - OAuthTokenResponse { 37 - access_token: self.access_token.into_static(), 38 - token_type: self.token_type, 39 - expires_in: self.expires_in, 40 - refresh_token: self.refresh_token.map(|s| s.into_static()), 41 - scope: self.scope.map(|s| s.into_static()), 42 - sub: self.sub.map(|s| s.into_static()), 43 - } 44 - } 45 - } 46 - 47 - impl IntoStatic for OAuthParResponse<'_> { 48 - type Output = OAuthParResponse<'static>; 49 - 50 - fn into_static(self) -> Self::Output { 51 - OAuthParResponse { 52 - request_uri: self.request_uri.into_static(), 53 - expires_in: self.expires_in, 54 - } 55 - } 27 + pub sub: Option<SmolStr>, 56 28 }
+95
crates/jacquard-oauth/src/utils.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use elliptic_curve::SecretKey; 4 + use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr}; 5 + use jose_jwk::{Key, crypto}; 6 + use rand::{CryptoRng, RngCore, rngs::ThreadRng}; 7 + use sha2::{Digest, Sha256}; 8 + use smol_str::ToSmolStr; 9 + use std::cmp::Ordering; 10 + 11 + use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata}; 12 + 13 + pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> { 14 + for alg in allowed_algos { 15 + #[allow(clippy::single_match)] 16 + match alg.as_ref() { 17 + "ES256" => { 18 + return Some(Key::from(&crypto::Key::from( 19 + SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()), 20 + ))); 21 + } 22 + _ => { 23 + // TODO: Implement other algorithms? 24 + } 25 + } 26 + } 27 + None 28 + } 29 + 30 + pub fn generate_nonce() -> CowStr<'static> { 31 + URL_SAFE_NO_PAD 32 + .encode(get_random_values::<_, 16>(&mut ThreadRng::default())) 33 + .into() 34 + } 35 + 36 + pub fn generate_verifier() -> CowStr<'static> { 37 + URL_SAFE_NO_PAD 38 + .encode(get_random_values::<_, 43>(&mut ThreadRng::default())) 39 + .into() 40 + } 41 + 42 + pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN] 43 + where 44 + R: RngCore + CryptoRng, 45 + { 46 + let mut bytes = [0u8; LEN]; 47 + rng.fill_bytes(&mut bytes); 48 + bytes 49 + } 50 + 51 + // 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order) 52 + pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering { 53 + if a.as_ref() == "ES256K" { 54 + return Ordering::Less; 55 + } 56 + if b.as_ref() == "ES256K" { 57 + return Ordering::Greater; 58 + } 59 + for prefix in ["ES", "PS", "RS"] { 60 + if let Some(stripped_a) = a.strip_prefix(prefix) { 61 + if let Some(stripped_b) = b.strip_prefix(prefix) { 62 + if let (Ok(len_a), Ok(len_b)) = 63 + (stripped_a.parse::<u32>(), stripped_b.parse::<u32>()) 64 + { 65 + return len_a.cmp(&len_b); 66 + } 67 + } else { 68 + return Ordering::Less; 69 + } 70 + } else if b.starts_with(prefix) { 71 + return Ordering::Greater; 72 + } 73 + } 74 + Ordering::Equal 75 + } 76 + 77 + pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) { 78 + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 79 + let verifier = generate_verifier(); 80 + ( 81 + URL_SAFE_NO_PAD 82 + .encode(Sha256::digest(&verifier.as_str())) 83 + .into(), 84 + verifier, 85 + ) 86 + } 87 + 88 + pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> { 89 + let mut algs = metadata 90 + .dpop_signing_alg_values_supported 91 + .clone() 92 + .unwrap_or(vec![FALLBACK_ALG.into()]); 93 + algs.sort_by(compare_algos); 94 + generate_key(&algs) 95 + }
+1
crates/jacquard/src/client/at_client.rs
··· 124 124 pub async fn set_session(&self, session: AuthSession) -> Result<(), SessionStoreError> { 125 125 let s = session.clone(); 126 126 let did = s.did().clone().into_static(); 127 + self.refresh_lock.lock().await.replace(did.clone()); 127 128 self.tokens.set(did, session).await 128 129 } 129 130